Skip to content

Commit c8774d5

Browse files
committed
Initial commit: WhereIsIt Home Assistant Addon
0 parents  commit c8774d5

40 files changed

+4405
-0
lines changed

.github/workflows/builder.yaml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Build and Publish Add-on
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- master
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
packages: write
13+
14+
jobs:
15+
build:
16+
runs-on: ubuntu-latest
17+
name: Build ${{ matrix.arch }}
18+
strategy:
19+
matrix:
20+
arch: [aarch64, amd64]
21+
steps:
22+
- name: Check out repository
23+
uses: actions/checkout@v4
24+
25+
- name: Login to GitHub Container Registry
26+
uses: docker/login-action@v3
27+
with:
28+
registry: ghcr.io
29+
username: ${{ github.repository_owner }}
30+
password: ${{ secrets.GITHUB_TOKEN }}
31+
32+
- name: Build add-on
33+
uses: home-assistant/builder@master
34+
with:
35+
args: |
36+
--${{ matrix.arch }} \
37+
--target whereisit \
38+
--image "ghcr.io/d3l05/whereisit-{arch}" \
39+
--docker-hub "ghcr.io/d3l05" \
40+
--addon

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__pycache__/

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<p align="center">
2+
<img src="whereisit/logo.png" alt="WhereIsIt Logo" width="200" />
3+
</p>
4+
5+
# WhereIsIt - Home Assistant Addon
6+
7+
A physical storage management system for Home Assistant.
8+
9+
## Features
10+
- **Hierarchical Storage**: Units -> Boxes -> Items
11+
- **QR Codes**: Generate and print QR codes for boxes.
12+
- **Search**: Quickly find items or boxes.
13+
- **Mobile First**: Designed for the Home Assistant Companion App.
14+
15+
## Installation
16+
17+
[![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2FD3L05%2Fwhereisit)
18+
19+
1. Click the button above to add this repository to your Home Assistant instance.
20+
2. Install "WhereIsIt" from the Add-on Store.
21+
2. Start the addon.
22+
3. Open the app from the sidebar.
23+
24+
## Usage
25+
1. **Create Storage Unit**: Define a location (e.g., Garage, Attic).
26+
2. **Add Boxes**: Create boxes within units. The system generates a unique slug.
27+
3. **Add Items**: List contents of each box.
28+
4. **Connect**:
29+
- **QR Code**: Open a box view and click the QR icon. Print and stick to the box.
30+
31+
## Development
32+
- Frontend: Lit + Vite
33+
- Backend: Python + FastAPI + SQLAlchemy
34+
- Database: SQLite

repository.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
name: "WhereIsIt Add-on Repository"
2+
url: "https://github.com/D3L05/whereisit"
3+
maintainer: "D3L05"

whereisit/Dockerfile

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
ARG BUILD_FROM=ghcr.io/home-assistant/amd64-base:latest
2+
# Multi-stage build for frontend
3+
FROM node:20-alpine AS frontend-build
4+
WORKDIR /frontend
5+
COPY frontend/package.json frontend/package-lock.json ./
6+
RUN npm ci
7+
8+
# Force cache invalidation for source code
9+
ARG CACHEBUST=202602201900
10+
RUN echo "Cache bust: $CACHEBUST"
11+
12+
COPY frontend/ ./
13+
# Verify that source code is updated (fail if api/units/ is found)
14+
RUN ! grep "api/units/" src/views/home-view.js || (echo "ERROR: Stale source code detected! Trailing slash found." && exit 1)
15+
RUN npm run build
16+
17+
# Final image
18+
FROM $BUILD_FROM
19+
20+
# Install dependencies
21+
RUN apk add --no-cache \
22+
nginx \
23+
sqlite
24+
25+
# Python dependencies
26+
COPY requirements.txt /tmp/
27+
RUN pip install --no-cache-dir -r /tmp/requirements.txt
28+
29+
# Copy backend
30+
WORKDIR /app
31+
COPY app /app/app
32+
33+
# Copy built frontend
34+
COPY --from=frontend-build /frontend/dist /app/frontend/dist
35+
36+
# Copy nginx config
37+
COPY rootfs /
38+
39+
# Permissions
40+
RUN chmod a+x /etc/services.d/whereisit/run

whereisit/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<p align="center">
2+
<img src="logo.png" alt="WhereIsIt Logo" width="200" />
3+
</p>
4+
5+
# WhereIsIt - Home Assistant Addon
6+
7+
A physical storage management system for Home Assistant.
8+
9+
## Features
10+
- **Hierarchical Storage**: Units -> Boxes -> Items
11+
- **QR Codes**: Generate and print QR codes for boxes.
12+
- **Search**: Quickly find items or boxes.
13+
- **Mobile First**: Designed for the Home Assistant Companion App.
14+
15+
## Installation
16+
17+
[![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2FD3L05%2Fwhereisit)
18+
19+
1. Click the button above to add this repository to your Home Assistant instance.
20+
2. Install "WhereIsIt" from the Add-on Store.
21+
2. Start the addon.
22+
3. Open the app from the sidebar.
23+
24+
## Usage
25+
1. **Create Storage Unit**: Define a location (e.g., Garage, Attic).
26+
2. **Add Boxes**: Create boxes within units. The system generates a unique slug.
27+
3. **Add Items**: List contents of each box.
28+
4. **Connect**:
29+
- **QR Code**: Open a box view and click the QR icon. Print and stick to the box.
30+
31+
## Development
32+
- Frontend: Lit + Vite
33+
- Backend: Python + FastAPI + SQLAlchemy
34+
- Database: SQLite

whereisit/app/api/endpoints.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from fastapi import APIRouter, Depends, HTTPException, Response
2+
from sqlalchemy.ext.asyncio import AsyncSession
3+
from typing import List
4+
from .. import crud, schemas, database, utils
5+
6+
router = APIRouter()
7+
8+
@router.get("/boxes/{box_id}/qrcode")
9+
async def get_box_qrcode(box_id: int, db: AsyncSession = Depends(database.get_db)):
10+
db_box = await crud.get_box(db, box_id=box_id)
11+
if db_box is None:
12+
raise HTTPException(status_code=404, detail="Box not found")
13+
14+
# URL structure: /api/hassio_ingress/{slug}/#/box/{slug}
15+
# Note: Ingress path handling might require adjustment based on actual deployment
16+
# For now, we generate a relative URL or a full URL if domain is known
17+
# Using the box slug for the URL
18+
qr_data = f"/hassio/ingress/whereisit/#/box/{db_box.slug}"
19+
20+
img_bytes = utils.generate_qr_code(qr_data)
21+
return Response(content=img_bytes, media_type="image/png")
22+
23+
@router.post("/units", response_model=schemas.UnitResponse)
24+
async def create_unit(unit: schemas.UnitCreate, db: AsyncSession = Depends(database.get_db)):
25+
db_unit = await crud.create_unit(db=db, unit=unit)
26+
# Return explicit dict to avoid ANY lazy loading risk during Pydantic validation
27+
return {"id": db_unit.id, "name": db_unit.name, "description": db_unit.description}
28+
29+
@router.get("/units", response_model=List[schemas.Unit])
30+
async def read_units(skip: int = 0, limit: int = 100, db: AsyncSession = Depends(database.get_db)):
31+
return await crud.get_units(db, skip=skip, limit=limit)
32+
33+
@router.get("/units/{unit_id}", response_model=schemas.Unit)
34+
async def read_unit(unit_id: int, db: AsyncSession = Depends(database.get_db)):
35+
db_unit = await crud.get_unit(db, unit_id=unit_id)
36+
if db_unit is None:
37+
raise HTTPException(status_code=404, detail="Unit not found")
38+
return db_unit
39+
40+
@router.put("/units/{unit_id}", response_model=schemas.UnitResponse)
41+
async def update_unit(unit_id: int, unit_update: schemas.UnitUpdate, db: AsyncSession = Depends(database.get_db)):
42+
db_unit = await crud.update_unit(db, unit_id=unit_id, unit_update=unit_update)
43+
if not db_unit:
44+
raise HTTPException(status_code=404, detail="Unit not found")
45+
return {"id": db_unit.id, "name": db_unit.name, "description": db_unit.description}
46+
47+
@router.delete("/units/{unit_id}")
48+
async def delete_unit(unit_id: int, db: AsyncSession = Depends(database.get_db)):
49+
db_unit = await crud.delete_unit(db, unit_id=unit_id)
50+
if not db_unit:
51+
raise HTTPException(status_code=404, detail="Unit not found")
52+
return {"message": "Unit deleted successfully"}
53+
54+
@router.post("/boxes", response_model=schemas.BoxSummary)
55+
async def create_box(box: schemas.BoxCreate, db: AsyncSession = Depends(database.get_db)):
56+
db_box = await crud.create_box(db=db, box=box)
57+
# Return explicit dict to avoid Pydantic trying to lazily load the un-fetched db_box.items relationship
58+
return {"id": db_box.id, "name": db_box.name, "description": db_box.description, "slug": db_box.slug, "unit_id": db_box.unit_id}
59+
60+
@router.put("/boxes/{box_id}", response_model=schemas.BoxSummary)
61+
async def update_box(box_id: int, box_update: schemas.BoxUpdate, db: AsyncSession = Depends(database.get_db)):
62+
db_box = await crud.update_box(db, box_id=box_id, box_update=box_update)
63+
if not db_box:
64+
raise HTTPException(status_code=404, detail="Box not found")
65+
return {"id": db_box.id, "name": db_box.name, "description": db_box.description, "slug": db_box.slug, "unit_id": db_box.unit_id}
66+
67+
@router.delete("/boxes/{box_id}")
68+
async def delete_box(box_id: int, db: AsyncSession = Depends(database.get_db)):
69+
db_box = await crud.delete_box(db, box_id=box_id)
70+
if not db_box:
71+
raise HTTPException(status_code=404, detail="Box not found")
72+
return {"message": "Box deleted successfully"}
73+
74+
@router.get("/boxes/{box_id}", response_model=schemas.Box)
75+
async def read_box(box_id: int, db: AsyncSession = Depends(database.get_db)):
76+
db_box = await crud.get_box(db, box_id=box_id)
77+
if db_box is None:
78+
raise HTTPException(status_code=404, detail="Box not found")
79+
return db_box
80+
81+
@router.get("/boxes/slug/{slug}", response_model=schemas.Box)
82+
async def read_box_by_slug(slug: str, db: AsyncSession = Depends(database.get_db)):
83+
db_box = await crud.get_box_by_slug(db, slug=slug)
84+
if db_box is None:
85+
raise HTTPException(status_code=404, detail="Box not found")
86+
return db_box
87+
88+
@router.post("/boxes/{box_id}/items", response_model=schemas.Item)
89+
async def create_item(box_id: int, item: schemas.ItemCreate, db: AsyncSession = Depends(database.get_db)):
90+
return await crud.create_item(db=db, item=item, box_id=box_id)
91+
92+
@router.put("/items/{item_id}", response_model=schemas.Item)
93+
async def update_item(item_id: int, item_update: schemas.ItemUpdate, db: AsyncSession = Depends(database.get_db)):
94+
db_item = await crud.update_item(db, item_id=item_id, item_update=item_update)
95+
if not db_item:
96+
raise HTTPException(status_code=404, detail="Item not found")
97+
return db_item
98+
99+
@router.delete("/items/{item_id}")
100+
async def delete_item(item_id: int, db: AsyncSession = Depends(database.get_db)):
101+
db_item = await crud.delete_item(db, item_id=item_id)
102+
if not db_item:
103+
raise HTTPException(status_code=404, detail="Item not found")
104+
return {"message": "Item deleted successfully"}
105+
106+
@router.get("/search")
107+
async def search(q: str, db: AsyncSession = Depends(database.get_db)):
108+
return await crud.search_storage(db, q)

whereisit/app/crud.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
from sqlalchemy.ext.asyncio import AsyncSession
2+
from sqlalchemy.future import select
3+
from sqlalchemy.orm import selectinload
4+
from . import models, schemas
5+
import uuid
6+
7+
async def get_units(db: AsyncSession, skip: int = 0, limit: int = 100):
8+
result = await db.execute(
9+
select(models.StorageUnit)
10+
.options(selectinload(models.StorageUnit.boxes).selectinload(models.StorageBox.items))
11+
.offset(skip)
12+
.limit(limit)
13+
)
14+
return result.scalars().all()
15+
16+
async def create_unit(db: AsyncSession, unit: schemas.UnitCreate):
17+
db_unit = models.StorageUnit(name=unit.name, description=unit.description)
18+
db.add(db_unit)
19+
await db.commit()
20+
await db.refresh(db_unit)
21+
return db_unit
22+
23+
async def get_unit(db: AsyncSession, unit_id: int):
24+
result = await db.execute(
25+
select(models.StorageUnit)
26+
.options(selectinload(models.StorageUnit.boxes).selectinload(models.StorageBox.items))
27+
.where(models.StorageUnit.id == unit_id)
28+
)
29+
return result.scalar_one_or_none()
30+
31+
async def get_boxes(db: AsyncSession, skip: int = 0, limit: int = 100):
32+
result = await db.execute(select(models.StorageBox).options(selectinload(models.StorageBox.items)).offset(skip).limit(limit))
33+
return result.scalars().all()
34+
35+
async def create_box(db: AsyncSession, box: schemas.BoxCreate):
36+
# Generate a random slug if not provided
37+
slug = box.slug or str(uuid.uuid4())
38+
db_box = models.StorageBox(name=box.name, description=box.description, slug=slug, unit_id=box.unit_id)
39+
db.add(db_box)
40+
await db.commit()
41+
await db.refresh(db_box)
42+
return db_box
43+
44+
async def get_box(db: AsyncSession, box_id: int):
45+
result = await db.execute(select(models.StorageBox).options(selectinload(models.StorageBox.items)).where(models.StorageBox.id == box_id))
46+
return result.scalar_one_or_none()
47+
48+
async def get_box_by_slug(db: AsyncSession, slug: str):
49+
result = await db.execute(select(models.StorageBox).options(selectinload(models.StorageBox.items)).where(models.StorageBox.slug == slug))
50+
return result.scalar_one_or_none()
51+
52+
async def create_item(db: AsyncSession, item: schemas.ItemCreate, box_id: int):
53+
db_item = models.Item(**item.model_dump(), box_id=box_id)
54+
db.add(db_item)
55+
await db.commit()
56+
await db.refresh(db_item)
57+
return db_item
58+
59+
async def delete_item(db: AsyncSession, item_id: int):
60+
result = await db.execute(select(models.Item).where(models.Item.id == item_id))
61+
item = result.scalar_one_or_none()
62+
if item:
63+
await db.delete(item)
64+
await db.commit()
65+
return item
66+
67+
async def update_unit(db: AsyncSession, unit_id: int, unit_update: schemas.UnitUpdate):
68+
db_unit = await get_unit(db, unit_id)
69+
if db_unit:
70+
update_data = unit_update.model_dump(exclude_unset=True)
71+
for key, value in update_data.items():
72+
setattr(db_unit, key, value)
73+
await db.commit()
74+
await db.refresh(db_unit)
75+
return db_unit
76+
77+
async def delete_unit(db: AsyncSession, unit_id: int):
78+
db_unit = await get_unit(db, unit_id)
79+
if db_unit:
80+
await db.delete(db_unit)
81+
await db.commit()
82+
return db_unit
83+
84+
async def update_box(db: AsyncSession, box_id: int, box_update: schemas.BoxUpdate):
85+
db_box = await get_box(db, box_id)
86+
if db_box:
87+
update_data = box_update.model_dump(exclude_unset=True)
88+
for key, value in update_data.items():
89+
setattr(db_box, key, value)
90+
await db.commit()
91+
await db.refresh(db_box)
92+
return db_box
93+
94+
async def delete_box(db: AsyncSession, box_id: int):
95+
db_box = await get_box(db, box_id)
96+
if db_box:
97+
await db.delete(db_box)
98+
await db.commit()
99+
return db_box
100+
101+
async def update_item(db: AsyncSession, item_id: int, item_update: schemas.ItemUpdate):
102+
result = await db.execute(select(models.Item).where(models.Item.id == item_id))
103+
db_item = result.scalar_one_or_none()
104+
if db_item:
105+
update_data = item_update.model_dump(exclude_unset=True)
106+
for key, value in update_data.items():
107+
setattr(db_item, key, value)
108+
await db.commit()
109+
await db.refresh(db_item)
110+
return db_item
111+
112+
async def search_storage(db: AsyncSession, query: str):
113+
# Search boxes
114+
boxes = await db.execute(
115+
select(models.StorageBox)
116+
.options(selectinload(models.StorageBox.items))
117+
.where(models.StorageBox.name.ilike(f"%{query}%"))
118+
)
119+
boxes = boxes.scalars().all()
120+
121+
# Search items
122+
items = await db.execute(select(models.Item).options(selectinload(models.Item.box)).where(models.Item.name.ilike(f"%{query}%")))
123+
items = items.scalars().all()
124+
125+
return {"boxes": boxes, "items": items}

0 commit comments

Comments
 (0)