diff --git a/.dockerignore b/.dockerignore index 600cdd1..c9d041c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,5 @@ !/sounds/* !/wakewords/* !/wakewords/openWakeWord/* +!/version.txt +!/version_githash.txt diff --git a/.github/workflows/docker-build-branch.yml b/.github/workflows/docker-build-branch.yml index c37f748..63b8e5b 100644 --- a/.github/workflows/docker-build-branch.yml +++ b/.github/workflows/docker-build-branch.yml @@ -44,6 +44,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Create version files + run: | + echo "${{ env.CLEAN_BRANCH_NAME }}" > version.txt + echo "${{ github.sha }}" > version_githash.txt + ls -ltr + - name: Build and push Docker image id: push uses: docker/build-push-action@v5.3.0 diff --git a/.github/workflows/docker-build-nightly.yml b/.github/workflows/docker-build-nightly.yml index 1b07f90..636cf64 100644 --- a/.github/workflows/docker-build-nightly.yml +++ b/.github/workflows/docker-build-nightly.yml @@ -25,6 +25,7 @@ jobs: run: | echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + - name: Log in to the Container registry uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0 with: @@ -36,6 +37,13 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + + - name: Create version files + run: | + echo "nightly" > version.txt + echo "${{ github.sha }}" > version_githash.txt + ls -ltr + - name: Build and push Docker image id: push uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0 diff --git a/.github/workflows/docker-build-release.yml b/.github/workflows/docker-build-release.yml index 1c2f7b1..67e0690 100644 --- a/.github/workflows/docker-build-release.yml +++ b/.github/workflows/docker-build-release.yml @@ -45,6 +45,13 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + + - name: Create version files + run: | + echo "${{ steps.meta.outputs.version }}" > version.txt + echo "${{ github.sha }}" > version_githash.txt + ls -ltr + - name: Build and push Docker image id: push uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0 diff --git a/.gitignore b/.gitignore index e5462da..1a4d19b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ venv/ # Docker .env +version.txt +version_githash.txt diff --git a/Dockerfile b/Dockerfile index 082deb3..14eb2f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,8 @@ COPY sounds/ ./sounds/ COPY ../wakewords/ ./wakewords/ COPY linux_voice_assistant/ ./linux_voice_assistant/ COPY docker-entrypoint.sh ./ +COPY version.txt ./ +COPY version_githash.txt ./ ### Run installation: RUN chmod +x docker-entrypoint.sh diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0156882..ddb405a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -47,6 +47,8 @@ services: - ${LVA_XDG_RUNTIME_DIR}:${LVA_XDG_RUNTIME_DIR} # Mount source code for development - ./linux_voice_assistant:/app/linux_voice_assistant + - ./version.txt:/app/version.txt + - ./version_githash.txt:/app/version_githash.txt depends_on: - fix-permissions # Health check for process diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index 7f303e0..df42b7c 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -22,6 +22,8 @@ from getmac import get_mac_address from .zeroconf import HomeAssistantZeroconf from .util import get_default_interface from .util import get_default_ipv4 +from .util import get_version +from .util import get_esphome_version _LOGGER = logging.getLogger(__name__) _MODULE_DIR = Path(__file__).parent @@ -183,13 +185,25 @@ async def main() -> None: # Resolve name if not args.name: - print("No name specified, try to autogenerate name") - device_name = f"lva-{mac_address_clean}" - print(f"Name autogenerated: {device_name}") + print("No friendly name specified, try to autogenerate name") + friendly_name = f"LVA - {mac_address_clean}" + print(f"Friendly name autogenerated: {friendly_name}") else: - print("Name specified") - print(f"Using name: {args.name}") - device_name = args.name + print("Friendly name specified") + print(f"Using friendly name: {args.name}") + friendly_name = args.name + + mac_no_colon = mac_address.replace(":", "").lower() + device_name = f"lva-{mac_no_colon}" + print(f"Device name: {device_name}") + + # Resolve version + version = get_version() + print(f"Version: {version}") + + # Resolve esphome version + esphome_version = get_esphome_version() + print(f"ESPHome api version: {esphome_version}") # Resolve download dir args.download_dir = Path(args.download_dir) @@ -293,9 +307,12 @@ async def main() -> None: state = ServerState( name=device_name, + friendly_name=friendly_name, network_interface=network_interface, mac_address=get_mac_address(interface=network_interface), ip_address=host_ip_address, + version=version, + esphome_version=esphome_version, audio_queue=Queue(), entities=[], available_wake_words=available_wake_words, diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py index e72f1a8..040e194 100644 --- a/linux_voice_assistant/models.py +++ b/linux_voice_assistant/models.py @@ -64,9 +64,12 @@ class Preferences: @dataclass class ServerState: name: str + friendly_name: str mac_address: str ip_address: str network_interface: str + version: str + esphome_version: str audio_queue: "Queue[Optional[bytes]]" entities: "List[ESPHomeEntity]" available_wake_words: "Dict[str, AvailableWakeWord]" diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py index 7e4ac77..fff3f1a 100644 --- a/linux_voice_assistant/satellite.py +++ b/linux_voice_assistant/satellite.py @@ -294,15 +294,15 @@ class VoiceSatelliteProtocol(APIServer): elif isinstance(msg, VoiceAssistantTimerEventResponse): self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg) elif isinstance(msg, DeviceInfoRequest): - # Compute dynamic device name - base_name = re.sub(r"[\s-]+", "-", self.state.name.lower()).strip("-") - mac_no_colon = self.state.mac_address.replace(":", "").lower() - mac_last6 = mac_no_colon[-6:] - device_name = f"{base_name}-{mac_last6}" + _LOGGER.debug("Device info request") yield DeviceInfoResponse( uses_password=False, - name=device_name, + name=self.state.name, + friendly_name=self.state.friendly_name, + project_name="Open Home Foundation.Linux Voice Assistant", + project_version=self.state.version, + esphome_version=self.state.esphome_version, mac_address=self.state.mac_address, manufacturer="Open Home Foundation", model="Linux Voice Assistant", diff --git a/linux_voice_assistant/util.py b/linux_voice_assistant/util.py index 77131cf..509cd37 100644 --- a/linux_voice_assistant/util.py +++ b/linux_voice_assistant/util.py @@ -4,8 +4,68 @@ import uuid # netifaces lib is from netifaces2 import netifaces from collections.abc import Callable +from importlib.metadata import version, PackageNotFoundError +from pathlib import Path from typing import Optional +# Cache for version to avoid repeated file reading +_version_cache: Optional[str] = None +_esphome_version_cache: Optional[str] = None + + +def get_version() -> str: + """ + Read the version from version.txt file. + + This function reads the content safely without risk of code injection, + as it only reads raw text and performs no evaluation. + + Returns: + str: The version from version.txt or 'unknown' if the file + does not exist or cannot be read. + """ + global _version_cache + + if _version_cache is not None: + return _version_cache + + version_file = Path(__file__).parent.parent / "version.txt" + + try: + # Sicher lesen: nur Rohtext, keine Evaluierung + version = version_file.read_text(encoding="utf-8").strip() + _version_cache = version if version else "unknown" + except (FileNotFoundError, PermissionError, OSError): + _version_cache = "unknown" + + return _version_cache + + +def get_esphome_version() -> str: + """ + Read the version of the installed aioesphomeapi package. + + This function uses importlib.metadata to safely retrieve the version + of an installed Python package without executing any code from the + package itself. + + Returns: + str: The version of aioesphomeapi (e.g., '42.7.0'), or 'unknown' + if the package is not installed or the version cannot be read. + """ + global _esphome_version_cache + + if _esphome_version_cache is not None: + return _esphome_version_cache + + try: + _esphome_version_cache = version("aioesphomeapi") + except PackageNotFoundError: + _esphome_version_cache = "unknown" + + return _esphome_version_cache + + def call_all(*callables: Optional[Callable[[], None]]) -> None: for item in filter(None, callables): item() diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..b3a4252 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +placeholder \ No newline at end of file diff --git a/version_githash.txt b/version_githash.txt new file mode 100644 index 0000000..b3a4252 --- /dev/null +++ b/version_githash.txt @@ -0,0 +1 @@ +placeholder \ No newline at end of file