Skip to content
Merged
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
21 changes: 21 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "Home Assistant Custom Card Dev",
"build": {
"dockerfile": "../Dockerfile",
"context": ".."
},
"forwardPorts": [8123],
"postCreateCommand": "container setup",
"remoteUser": "vscode",
"mounts": [
"source=${localWorkspaceFolder},target=/config/www/workspace,type=bind,consistency=cached"
],
"customizations": {
"vscode": {
"extensions": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint"
]
}
}
}
28 changes: 28 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Build

on:
push:
pull_request:
workflow_dispatch:

jobs:
build:
name: Build Docker image
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build image (no push)
uses: docker/build-push-action@v6
with:
context: .
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
62 changes: 62 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Release

on:
push:
tags:
- "v*.*.*"

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-and-push:
name: Build and push Docker image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Detect pre-release
id: prerelease
run: |
if [[ "${{ github.ref_name }}" == *-* ]]; then
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
fi

- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.prerelease.outputs.is_prerelease == 'false' }}
type=raw,value=latest,enable=${{ steps.prerelease.outputs.is_prerelease == 'false' }}
type=raw,value=beta,enable=${{ steps.prerelease.outputs.is_prerelease == 'true' }}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
49 changes: 49 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
FROM mcr.microsoft.com/devcontainers/python:1-3.13

SHELL ["/bin/bash", "-o", "pipefail", "-c"]

RUN rm -f /etc/apt/sources.list.d/yarn.list \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
bluez \
libffi-dev \
libssl-dev \
libjpeg-dev \
zlib1g-dev \
autoconf \
build-essential \
libopenjp2-7 \
libtiff6 \
libturbojpeg0-dev \
tzdata \
ffmpeg \
liblapack3 \
liblapack-dev \
libatlas-base-dev \
git \
libpcap-dev \
unzip \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& source /usr/local/share/nvm/nvm.sh \
&& nvm install --lts \
&& pip install --upgrade wheel pip uv

COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc

EXPOSE 8123

VOLUME /config

USER vscode
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
RUN uv venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"

COPY requirements.txt /tmp/requirements.txt
RUN uv pip install -r /tmp/requirements.txt

COPY --chmod=0755 container /usr/local/bin/container
COPY --chmod=0755 hassfest /usr/local/bin/hassfest

CMD ["sudo", "-E", "container"]
105 changes: 104 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,105 @@
# custom-card-devcontainer
A devcontainer for developing Home Assistant custom cards

A devcontainer for developing and testing Home Assistant custom cards.

## Features

- Latest Home Assistant installed from PyPI
- [HACS](https://hacs.xyz) installed automatically (skipped if already up to date)
- Node.js LTS via nvm for building and bundling cards
- Fast Python dependency installation via [uv](https://github.com/astral-sh/uv)
- go2rtc for full Home Assistant media support
- Compatible with GitHub Codespaces and VS Code Dev Containers

## Usage

### GitHub Codespaces / VS Code Dev Containers

This repository includes a `.devcontainer/devcontainer.json` you can use directly or adapt for your own project.

The image is published to the [GitHub Container Registry](https://ghcr.io/custom-cards/custom-card-devcontainer) on every tagged release. Images are tagged with the full version (`1.2.3`), minor version (`1.2`), and `latest`.

For reproducible environments, pin to a specific version tag:

```bash
docker pull ghcr.io/custom-cards/custom-card-devcontainer:1.2.3
```

### Beta / pre-release images

Pre-release tags (e.g. `v1.2.3-beta.1`) are published with the exact version and a floating `beta` tag. The `latest` tag is **not** updated for pre-releases.

To test a beta image, use the `beta` tag or pin to the exact pre-release version:

```bash
docker pull ghcr.io/custom-cards/custom-card-devcontainer:beta
docker pull ghcr.io/custom-cards/custom-card-devcontainer:1.2.3-beta.1
```

For your own custom card project, add a `.devcontainer/devcontainer.json`:

```json
{
"image": "ghcr.io/custom-cards/custom-card-devcontainer:1.2.3",
"forwardPorts": [8123],
"postCreateCommand": "container setup",
"remoteUser": "vscode",
"mounts": [
"source=${localWorkspaceFolder},target=/config/www/workspace,type=bind,consistency=cached"
],
"runArgs": ["--env-file", "${localWorkspaceFolder}/.env"]
}
```

### Docker

```bash
docker run --rm -it \
-p 8123:8123 \
-v $(pwd):/config/www/workspace \
-e LOVELACE_LOCAL_FILES="my-card.js" \
ghcr.io/custom-cards/custom-card-devcontainer
```

## Environment Variables

| Name | Description | Default |
|------|-------------|---------|
| `HASS_USERNAME` | Username of the default Home Assistant user | `dev` |
| `HASS_PASSWORD` | Password of the default Home Assistant user | `dev` |
| `LOVELACE_LOCAL_FILES` | Space-separated list of filenames in `/config/www/workspace` to register as Lovelace resources | Empty |
| `LOVELACE_REMOTE_FILES` | Space-separated list of full URLs for remotely served Lovelace resources (e.g. a local dev server) | Empty |

### LOVELACE_LOCAL_FILES

Use this for the card file(s) you are actively developing. The files must be present in your workspace, which is mounted at `/config/www/workspace`.

```
LOVELACE_LOCAL_FILES="my-card.js other-card.js"
```

### LOVELACE_REMOTE_FILES

Use this for resources served by a separate process (e.g. a Webpack or Rollup dev server). Provide full URLs including host and port.

```
LOVELACE_REMOTE_FILES="http://localhost:5000/my-card.js http://localhost:5001/other-card.js"
```

## Container Script

| Command | Description |
|---------|-------------|
| `container` | Set up Home Assistant and launch it |
| `container setup` | Perform setup (config, user, HACS, Lovelace resources) without launching |
| `container launch` | Launch Home Assistant with `hass -c /config -v` |

## hassfest

If you are also developing a custom integration, you can validate it with:

```bash
hassfest
```

This will clone the Home Assistant source on first run (into `/usr/src/homeassistant`) and run `hassfest` validation against any integrations found in `/config/custom_components`.
125 changes: 125 additions & 0 deletions container
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/bin/bash

function ensure_hass_config() {
hass --script ensure_config -c /config
}

function create_hass_user() {
local username="${HASS_USERNAME:-dev}"
local password="${HASS_PASSWORD:-dev}"
echo "Creating Home Assistant user ${username}"
hass --script auth -c /config add "${username}" "${password}"
}

function bypass_onboarding() {
mkdir -p /config/.storage
cat > /config/.storage/onboarding << 'EOF'
{
"data": {
"done": [
"user",
"core_config",
"integration"
]
},
"key": "onboarding",
"version": 3
}
EOF
}

function install_hacs() {
local hacs_dir="/config/custom_components/hacs"
local hacs_manifest="${hacs_dir}/manifest.json"

echo "Checking HACS version..."
local latest_version
latest_version=$(curl -sf "https://api.github.com/repos/hacs/integration/releases/latest" \
| python3 -c "import sys, json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null || true)

if [[ -z "${latest_version}" ]]; then
echo "Warning: Could not determine latest HACS version (network issue or API rate limit). Installing via get.hacs.xyz..."
curl -sfSL https://get.hacs.xyz | bash -
return
fi

if [[ -f "${hacs_manifest}" ]]; then
local installed_version
installed_version=$(python3 -c "import json; print(json.load(open('${hacs_manifest}'))['version'])" 2>/dev/null || true)
if [[ "${installed_version}" == "${latest_version}" ]]; then
echo "HACS ${installed_version} is already up to date, skipping download"
return
fi
echo "Updating HACS from ${installed_version} to ${latest_version}"
else
echo "Installing HACS ${latest_version}"
fi

mkdir -p "${hacs_dir}"
curl -sL "https://github.com/hacs/integration/releases/download/${latest_version}/hacs.zip" \
-o /tmp/hacs.zip
unzip -oq /tmp/hacs.zip -d "${hacs_dir}"
rm -f /tmp/hacs.zip
echo "HACS ${latest_version} installed"
}

function install_lovelace_resources() {
local resources=()

if [[ -n "${LOVELACE_LOCAL_FILES:-}" ]]; then
mkdir -p /config/www/workspace
for file in ${LOVELACE_LOCAL_FILES}; do
resources+=("/local/workspace/${file}")
done
fi

if [[ -n "${LOVELACE_REMOTE_FILES:-}" ]]; then
for url in ${LOVELACE_REMOTE_FILES}; do
resources+=("${url}")
done
fi

if [[ ${#resources[@]} -eq 0 ]]; then
return
fi

mkdir -p /config/.storage
python3 - "${resources[@]}" << 'PYEOF'
import sys, json

urls = sys.argv[1:]
items = [{"id": str(i + 1), "type": "module", "url": url} for i, url in enumerate(urls)]
storage = {"data": {"items": items}, "key": "lovelace_resources", "version": 1}
with open("/config/.storage/lovelace_resources", "w") as f:
json.dump(storage, f, indent=4)
PYEOF
}

function setup() {
ensure_hass_config
create_hass_user
bypass_onboarding
install_hacs
install_lovelace_resources
}

function run() {
hass -c /config -v
}

if [[ -f "${ENV_FILE:-}" ]]; then
source "${ENV_FILE}"
fi

case "${1:-}" in
setup)
setup
;;
launch)
run
;;
*)
setup
run
;;
esac
Loading