Skip to content
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
23 changes: 23 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
web/node_modules/
web/.pnpm-store/
dist/
data/
.venv/
__pycache__/
**/__pycache__/
**/*.pyc
**/*.pyo
.git/
screenshots/
docs/
*.zip
*.md
sonar-project.properties
*.egg-info/
build/
README.md
LICENSE
Makefile
.github/
icon.png
logo.png
10 changes: 10 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
root = true

[*]
print_width = 120
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
25 changes: 16 additions & 9 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,28 @@ docs/*
.superpowers/

# Python
broadlinkmanager/__pycache__/
broadlinkmanager/**/__pycache__/
broadlinkmanager/**/*.pyc
__pycache__/
**/__pycache__/
**/*.pyc

# Frontend build output (placeholder only, not the build)
broadlinkmanager/dist/*
!broadlinkmanager/dist/.gitkeep
dist/*
!dist/.gitkeep

# Runtime data — generated at startup, never commit
broadlinkmanager/data/*.db
broadlinkmanager/data/*.db-journal
broadlinkmanager/data/devices.json
data/*.db
data/*.db-journal
data/devices.json

# Node
broadlinkmanager/web/node_modules/
node_modules/
web/node_modules/
web/.pnpm-store/

# Testing & verification
test-mobile.mjs
verify-mobile.ts
screenshots/

# Temp scripts
modernize-templates.sh
Expand Down
44 changes: 44 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Backend: FastAPI server",
"type": "shell",
"command": "make run",
"presentation": {
"group": "dev",
"panel": "dedicated",
"reveal": "always"
},
"isBackground": true,
"problemMatcher": []
},
{
"label": "Frontend: Vite dev server",
"type": "shell",
"command": "make dev",
"presentation": {
"group": "dev",
"panel": "dedicated",
"reveal": "always"
},
"isBackground": true,
"problemMatcher": []
},
{
"label": "Dev: Start all",
"dependsOn": ["Backend: FastAPI server", "Frontend: Vite dev server"],
"dependsOrder": "parallel",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"group": "dev",
"panel": "dedicated",
"reveal": "always"
},
"problemMatcher": []
}
]
}
38 changes: 20 additions & 18 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
# Stage 1 — Build React frontend
FROM node:20-alpine AS frontend
FROM node:24-alpine AS frontend
WORKDIR /app/web
COPY broadlinkmanager/web/package*.json ./
RUN npm ci --silent
COPY broadlinkmanager/web/ ./
RUN npm run build
RUN corepack enable pnpm
COPY web/package.json web/pnpm-lock.yaml ./
RUN mkdir -p ~/.pnpm-store && \
echo 'strict-peer-dependencies=false' > /root/.pnpmrc && \
pnpm install --frozen-lockfile --force-integrity-check=false 2>&1 || pnpm install --no-lockfile
COPY web/ ./
RUN pnpm run build && rm -rf node_modules ~/.pnpm-store
# Vite outDir is '../dist' so output lands at /app/dist

# Stage 2 — Python runtime
# Stage 2 — Runtime
FROM python:3.12-slim

LABEL maintainer="tomer.klein@gmail.com"

ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1

UV_NO_CACHE=1 \
UV_SYSTEM_PYTHON=1
WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
iputils-ping \
&& rm -rf /var/lib/apt/lists/*

COPY broadlinkmanager/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application source
COPY broadlinkmanager/ /app/

COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml .
RUN uv pip install . && rm -rf /root/.cache
COPY server.py VERSION ./
COPY app/ ./app/
COPY broadlink/ ./broadlink/
COPY --from=frontend /app/dist ./dist/
EXPOSE 7020
CMD ["python", "server.py"]
55 changes: 55 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
DOCKER_IMAGE = clabnet/broadlinkmanager
VERSION = $(shell cat VERSION)
PORT = 7020

# ── Python / deps ────────────────────────────────────────────────────────────

.PHONY: venv install

venv:
uv venv

install:
uv pip install .
cd web && pnpm install


# ── Frontend ──────────────────────────────────────────────────────────────────

.PHONY: dev build

dev:
cd web && pnpm dev --host

build:
cd web && pnpm build

# ── Run locally ───────────────────────────────────────────────────────────────

.PHONY: run

run:
uv run python server.py

# ── Screenshots ──────────────────────────────────────────────────────────────

.PHONY: screenshots

screenshots:
@echo "📸 Capturing screenshots of all pages..."
@echo " Make sure dev server is running: make dev"
pnpm add -D playwright
node scripts/screenshots.mjs

# ── Docker ────────────────────────────────────────────────────────────────────

.PHONY: docker-build docker-build-nocache logs

docker-build:
docker build --file Dockerfile -t $(DOCKER_IMAGE) .

docker-build-nocache:
docker build --progress=plain --no-cache --file Dockerfile -t $(DOCKER_IMAGE) .

logs:
docker logs -f broadlinkmanager
41 changes: 37 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,18 @@ services:
broadlinkmanager:
image: techblog/broadlinkmanager
container_name: broadlinkmanager
network_mode: host
restart: unless-stopped
ports:
- "7020:7020"
volumes:
- ./data:/app/data
environment:
- DISCOVERY_DST_IP=192.168.1.255 # set to your subnet broadcast
```

> **Why `network_mode: host`?** Broadlink device discovery uses UDP broadcast packets on the local network. Host networking allows the container to send and receive those broadcasts. Without it, auto-discovery will not find any devices.
> **Docker Desktop (Windows/Mac):** the default bridge/NAT network blocks UDP broadcast, so the scan will find nothing without extra configuration. Set `DISCOVERY_DST_IP` to your subnet broadcast address and/or specific device IPs (e.g. `192.168.1.255,192.168.1.199`) — unicast replies route back through NAT conntrack.

> **Linux hosts:** for the simplest setup replace `ports:` with `network_mode: host` so the container joins the host network directly and broadcast discovery works with no extra config.

Once the container is running, open your browser at:
```
Expand All @@ -51,6 +56,8 @@ http://<docker-host-ip>:7020
| Variable | Default | Description |
|---|---|---|
| `DISCOVERY_IP_LIST` | *(auto-detected)* | Comma-separated list of local IP addresses to use for device discovery. Useful when the container has multiple network interfaces. Example: `192.168.1.10,192.168.2.10` |
| `DISCOVERY_DST_IP` | `255.255.255.255` | Comma-separated list of destination addresses for the discovery packet: broadcast addresses and/or specific device IPs. Example: `192.168.1.255,192.168.1.199` |
| `DISCOVERY_TIMEOUT` | `5` | Discovery timeout in seconds per scan |
| `DB_PATH` | `/app/data/codes.db` | Path to the SQLite database file for saved codes |

### CLI Flags
Expand All @@ -60,14 +67,40 @@ You can pass arguments directly to the container to override discovery behaviour
| Flag | Default | Description |
|---|---|---|
| `--ip <IP>` | *(auto)* | Specify a local interface IP for discovery (repeatable) |
| `--dst-ip <IP>` | `255.255.255.255` | Broadcast destination IP for discovery |
| `--dst-ip <IP[,IP…]>` | `255.255.255.255` | Comma-separated destination addresses for discovery (broadcast and/or device IPs) |
| `--timeout <s>` | `5` | Discovery timeout in seconds |

Example:
```yaml
command: ["python", "broadlinkmanager.py", "--ip", "192.168.1.50"]
command: ["python", "server.py", "--ip", "192.168.1.50"]
```

## Development

### VSCode Tasks

This project includes pre-configured VSCode tasks to streamline development. You can run tasks via:
- **Command Palette:** `Ctrl+Shift+P` → search "Run Task"
- **Default Task:** `Ctrl+Shift+B` runs "Dev: Start all"
- **Terminal:** `Ctrl+Shift+`` to open integrated terminal

Available tasks:

| Task | Command | Port | Description |
|---|---|---|---|
| **Dev: Start all** *(default)* | `make run` + `make dev` | 7020 + 5174 | Starts both backend and frontend dev servers |
| **Backend: FastAPI server** | `make run` | 7020 | Runs FastAPI development server |
| **Frontend: Vite dev server** | `make dev` | 5174 | Runs Vite dev server with hot module reloading |

Each task runs in its own dedicated terminal panel. Press `Ctrl+Shift+B` to start all services at once:
- **Frontend:** http://localhost:5174
- **Backend API:** http://localhost:7020

**Port Usage:**
- `5174` — Vite dev server (frontend)
- `7020` — FastAPI backend server
- `5173` — Reserved (do not use)

## Screenshots

### Devices — Dark Mode
Expand Down
File renamed without changes.
File renamed without changes.
37 changes: 29 additions & 8 deletions broadlinkmanager/app/config.py → app/config.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import os
import re
import subprocess
import socket
import argparse
import sys
from loguru import logger

ip_format_regex = r"\b(((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9]))\b"

# Populated by init_config() — empty until app startup
args: argparse.Namespace = argparse.Namespace(ip=[], timeout=5, dst_ip="255.255.255.255")
discovery_ip_address_list: list[str] = []
args: argparse.Namespace = argparse.Namespace(
ip=[], timeout=2, dst_ip="255.255.255.255", dst_ip_list=["255.255.255.255"]
)
discovery_ip_address_list: list[str | None] = []


def validate_ip(ip: str) -> bool:
Expand All @@ -22,8 +24,15 @@ def parse_ip_list(iplist: str) -> list[str]:


def get_local_ip_list() -> list[str]:
result = subprocess.run(["hostname", "-I"], capture_output=True, text=True)
ips = parse_ip_list(result.stdout)
# Cross-platform default-route lookup (`hostname -I` is Linux-only).
# connect() on a UDP socket sends no traffic; it only picks the local address.
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
ips = [s.getsockname()[0]]
except OSError as e:
logger.debug(f"Local IP detection failed: {e}")
ips = []
logger.debug(f"Local IP list: {ips}")
return ips

Expand Down Expand Up @@ -52,15 +61,23 @@ def init_config() -> None:
# Mutate in-place so modules that did `from app.config import X` see the
# updated values — reassigning would create new objects and leave those
# imported names pointing at the old empty defaults.

# Env vars provide defaults; CLI args still win
env_timeout = os.getenv("DISCOVERY_TIMEOUT", "2")
env_dst_ip = os.getenv("DISCOVERY_DST_IP", "")

parser = argparse.ArgumentParser(fromfile_prefix_chars="@")
parser.add_argument("--timeout", type=int, default=5)
parser.add_argument("--timeout", type=int, default=int(env_timeout) if env_timeout.isdigit() else 5)
parser.add_argument("--ip", action="append", default=[])
parser.add_argument("--dst-ip", default="255.255.255.255")
parser.add_argument("--dst-ip", default=env_dst_ip or "255.255.255.255")
parsed = parser.parse_args()

# Mutate args in-place rather than rebinding
vars(args).update(vars(parsed))

# dst-ip may be a comma-separated list of broadcast and/or device addresses
args.dst_ip_list = parse_ip_list(args.dst_ip) or ["255.255.255.255"]

if args.ip:
invalid = [ip for ip in args.ip if not validate_ip(ip)]
if invalid:
Expand All @@ -71,8 +88,12 @@ def init_config() -> None:
env_list = get_env_ip_list()
resolved = env_list if env_list else get_local_ip_list()

if not resolved:
logger.warning("No local IPs resolved; falling back to default binding 0.0.0.0")
resolved = [None]

# Mutate the list in-place rather than rebinding
discovery_ip_address_list.clear()
discovery_ip_address_list.extend(resolved)

logger.info(f"Discovery interfaces: {discovery_ip_address_list}")
logger.info(f"Discovery interfaces: {discovery_ip_address_list}, destinations: {args.dst_ip_list}")
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading