Skip to content

Commit 045d764

Browse files
code-rootclaude
andcommitted
feat: firmware entry database persistence — CRUD + auto-save + sync
Database Model (Phase 1): - Added FirmwareEntry table to store firmware metadata across restarts - Fields: source, device_model, sales_code, ap_version, csc_version, filename, local_path, notes, timestamps, package_variant, country, language API Endpoints (Phase 2): - GET /firmware/entries — list all entries with optional model/csc filtering - POST /firmware/entries — create entry manually (when Samsung FOTA returns "needs_url") - PUT /firmware/entries/{id} — update entry - DELETE /firmware/entries/{id} — delete entry - POST /firmware/sync — scan disk and import new packages to database Auto-Save: - Downloads that complete successfully (status=done) are automatically saved to database with source='auto' - Checks for duplicates by filename before creating new entries Frontend (Phase 3): - FirmwareManager.tsx: added "Add Firmware Entry" form with model/CSC/AP/CSC inputs - Database Entries section showing all catalog entries with source badges - "Sync Disk" button to import scanned packages - Delete buttons per entry - client.ts: added API functions for firmware entry CRUD + sync Use Cases: - Samsung FOTA returns "needs_url" → user manually adds entry via form - Downloaded firmware auto-persists to database - Users can browse and manage firmware catalog across restarts - Sync merges disk-scanned packages into database Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 570aef1 commit 045d764

5 files changed

Lines changed: 581 additions & 6 deletions

File tree

backend/api/routes/firmware_route.py

Lines changed: 236 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,20 @@
88
GET /api/firmware/jobs/{job_id} Get job status + progress
99
DELETE /api/firmware/jobs/{job_id} Cancel a download job
1010
GET /api/firmware/packages List locally available firmware packages
11+
GET /api/firmware/entries List firmware entries from database
12+
POST /api/firmware/entries Create new firmware entry
13+
PUT /api/firmware/entries/{id} Update firmware entry
14+
DELETE /api/firmware/entries/{id} Delete firmware entry
15+
POST /api/firmware/sync Scan disk and import new packages to database
1116
"""
1217

1318
from typing import Any, Dict, List, Literal, Optional
1419

1520
from fastapi import APIRouter, Depends, HTTPException
1621
from pydantic import BaseModel
22+
from sqlalchemy.orm import Session
1723

18-
from api.deps import get_current_user
24+
from api.deps import get_current_user, get_db
1925
from core.firmware.downloader import (
2026
DownloadJob,
2127
cancel_job,
@@ -25,8 +31,9 @@
2531
)
2632
from core.firmware.scan import iter_firmware_packages
2733
from config import settings
28-
from db.models import User
34+
from db.models import User, FirmwareEntry
2935
from pathlib import Path
36+
from datetime import datetime
3037

3138
router = APIRouter(prefix="/firmware", tags=["firmware"])
3239

@@ -62,6 +69,59 @@ class Config:
6269
from_attributes = True
6370

6471

72+
class FirmwareEntryCreate(BaseModel):
73+
"""Request to create a new firmware entry."""
74+
75+
source: str = "manual" # "manual", "samfw", "auto"
76+
device_model: str
77+
sales_code: str
78+
ap_version: Optional[str] = None
79+
csc_version: Optional[str] = None
80+
package_variant: Optional[str] = None
81+
country: Optional[str] = None
82+
language: Optional[str] = None
83+
filename: Optional[str] = None
84+
size_bytes: int = 0
85+
local_path: Optional[str] = None
86+
notes: Optional[str] = None
87+
88+
89+
class FirmwareEntryUpdate(BaseModel):
90+
"""Request to update a firmware entry."""
91+
92+
ap_version: Optional[str] = None
93+
csc_version: Optional[str] = None
94+
package_variant: Optional[str] = None
95+
country: Optional[str] = None
96+
language: Optional[str] = None
97+
size_bytes: Optional[int] = None
98+
local_path: Optional[str] = None
99+
notes: Optional[str] = None
100+
101+
102+
class FirmwareEntryResponse(BaseModel):
103+
"""Response with firmware entry data."""
104+
105+
id: int
106+
source: str
107+
device_model: str
108+
sales_code: str
109+
ap_version: Optional[str]
110+
csc_version: Optional[str]
111+
package_variant: Optional[str]
112+
country: Optional[str]
113+
language: Optional[str]
114+
filename: Optional[str]
115+
size_bytes: int
116+
local_path: Optional[str]
117+
notes: Optional[str]
118+
created_at: datetime
119+
updated_at: datetime
120+
121+
class Config:
122+
from_attributes = True
123+
124+
65125
@router.post("/download")
66126
async def start_download(
67127
req: FirmwareDownloadRequest, _user: User = Depends(get_current_user)
@@ -152,3 +212,177 @@ async def list_available_packages(
152212
"""
153213
firmware_dir = Path(settings.FIRMWARE_PACKAGES_DIR)
154214
return iter_firmware_packages(firmware_dir)
215+
216+
217+
# ─── Firmware Entry CRUD ──────────────────────────────────────────────────────
218+
219+
220+
@router.get("/entries")
221+
async def list_firmware_entries(
222+
model: Optional[str] = None,
223+
csc: Optional[str] = None,
224+
db: Session = Depends(get_db),
225+
_user: User = Depends(get_current_user),
226+
) -> List[FirmwareEntryResponse]:
227+
"""
228+
List firmware entries from database with optional filtering.
229+
230+
Query params:
231+
- model: Filter by device_model (e.g. "SM-A556B")
232+
- csc: Filter by sales_code (e.g. "XEU")
233+
234+
Returns all matching entries sorted by created_at descending.
235+
"""
236+
query = db.query(FirmwareEntry)
237+
238+
if model:
239+
query = query.filter(FirmwareEntry.device_model == model)
240+
if csc:
241+
query = query.filter(FirmwareEntry.sales_code == csc)
242+
243+
entries = query.order_by(FirmwareEntry.created_at.desc()).all()
244+
return [FirmwareEntryResponse.from_orm(e) for e in entries]
245+
246+
247+
@router.post("/entries")
248+
async def create_firmware_entry(
249+
req: FirmwareEntryCreate,
250+
db: Session = Depends(get_db),
251+
_user: User = Depends(get_current_user),
252+
) -> FirmwareEntryResponse:
253+
"""
254+
Create a new firmware entry manually.
255+
256+
Used when Samsung FOTA API returns "needs_url" or for manual catalog entries.
257+
258+
Request body:
259+
{
260+
"source": "manual",
261+
"device_model": "SM-A556B",
262+
"sales_code": "XEU",
263+
"ap_version": "A556BXXU3BXJ1",
264+
"csc_version": "A556BOXM3BXJ1",
265+
"notes": "from SamFW.com"
266+
}
267+
"""
268+
entry = FirmwareEntry(
269+
source=req.source,
270+
device_model=req.device_model,
271+
sales_code=req.sales_code,
272+
ap_version=req.ap_version,
273+
csc_version=req.csc_version,
274+
package_variant=req.package_variant,
275+
country=req.country,
276+
language=req.language,
277+
filename=req.filename,
278+
size_bytes=req.size_bytes,
279+
local_path=req.local_path,
280+
notes=req.notes,
281+
)
282+
db.add(entry)
283+
db.commit()
284+
db.refresh(entry)
285+
return FirmwareEntryResponse.from_orm(entry)
286+
287+
288+
@router.put("/entries/{entry_id}")
289+
async def update_firmware_entry(
290+
entry_id: int,
291+
req: FirmwareEntryUpdate,
292+
db: Session = Depends(get_db),
293+
_user: User = Depends(get_current_user),
294+
) -> FirmwareEntryResponse:
295+
"""
296+
Update a firmware entry.
297+
298+
Path params:
299+
- entry_id: ID of the entry to update
300+
301+
Request body contains only fields to update (others left unchanged).
302+
"""
303+
entry = db.query(FirmwareEntry).filter(FirmwareEntry.id == entry_id).first()
304+
if not entry:
305+
raise HTTPException(404, f"Firmware entry {entry_id} not found")
306+
307+
update_data = req.model_dump(exclude_unset=True)
308+
for key, value in update_data.items():
309+
setattr(entry, key, value)
310+
311+
db.commit()
312+
db.refresh(entry)
313+
return FirmwareEntryResponse.from_orm(entry)
314+
315+
316+
@router.delete("/entries/{entry_id}")
317+
async def delete_firmware_entry(
318+
entry_id: int,
319+
db: Session = Depends(get_db),
320+
_user: User = Depends(get_current_user),
321+
) -> Dict[str, Any]:
322+
"""
323+
Delete a firmware entry.
324+
325+
Path params:
326+
- entry_id: ID of the entry to delete
327+
328+
Note: Does NOT delete the actual firmware file (local_path).
329+
"""
330+
entry = db.query(FirmwareEntry).filter(FirmwareEntry.id == entry_id).first()
331+
if not entry:
332+
raise HTTPException(404, f"Firmware entry {entry_id} not found")
333+
334+
db.delete(entry)
335+
db.commit()
336+
return {"success": True, "entry_id": entry_id}
337+
338+
339+
@router.post("/sync")
340+
async def sync_firmware_entries(
341+
db: Session = Depends(get_db),
342+
_user: User = Depends(get_current_user),
343+
) -> Dict[str, Any]:
344+
"""
345+
Scan firmware directory and import new packages to database.
346+
347+
Scans FIRMWARE_PACKAGES_DIR for packages, checks if they already exist
348+
in the database (by filename or device_model + sales_code + ap_version),
349+
and creates new entries for any newly found packages.
350+
351+
Returns count of newly imported entries.
352+
"""
353+
firmware_dir = Path(settings.FIRMWARE_PACKAGES_DIR)
354+
packages = iter_firmware_packages(firmware_dir)
355+
356+
imported = 0
357+
for pkg in packages:
358+
# Check if entry already exists by filename or by model/csc/ap combo
359+
existing = None
360+
if pkg.get("filename"):
361+
existing = db.query(FirmwareEntry).filter(
362+
FirmwareEntry.filename == pkg["filename"]
363+
).first()
364+
365+
if not existing:
366+
# Try to match by model + csc + ap_version
367+
existing = db.query(FirmwareEntry).filter(
368+
FirmwareEntry.device_model == pkg.get("device_model"),
369+
FirmwareEntry.sales_code == pkg.get("sales_code"),
370+
FirmwareEntry.ap_version == pkg.get("ap_version"),
371+
).first()
372+
373+
if not existing:
374+
# Create new entry
375+
entry = FirmwareEntry(
376+
source="auto",
377+
device_model=pkg.get("device_model"),
378+
sales_code=pkg.get("sales_code"),
379+
ap_version=pkg.get("ap_version"),
380+
csc_version=pkg.get("csc_version"),
381+
filename=pkg.get("filename"),
382+
size_bytes=pkg.get("size_bytes", 0),
383+
)
384+
db.add(entry)
385+
imported += 1
386+
387+
db.commit()
388+
return {"success": True, "imported": imported}

backend/core/firmware/downloader.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424

2525
from config import settings
2626
from core.firmware.samsung_fota import fetch_latest_firmware
27+
from db.database import SessionLocal
28+
from db.models import FirmwareEntry
2729

2830
logger = logging.getLogger(__name__)
2931

@@ -170,6 +172,40 @@ async def _download_worker(job: DownloadJob, url: str) -> None:
170172
job.status = 'done'
171173
job.progress_pct = 100.0
172174

175+
# Auto-save to database
176+
try:
177+
db = SessionLocal()
178+
try:
179+
# Check if entry already exists by filename
180+
existing = db.query(FirmwareEntry).filter(
181+
FirmwareEntry.filename == dest_filename
182+
).first()
183+
184+
if existing:
185+
# Update existing entry
186+
existing.ap_version = job.ap_version
187+
existing.size_bytes = job.downloaded_bytes
188+
existing.local_path = str(dest_path)
189+
else:
190+
# Create new entry
191+
entry = FirmwareEntry(
192+
source='auto',
193+
device_model=job.model,
194+
sales_code=job.csc,
195+
ap_version=job.ap_version,
196+
filename=dest_filename,
197+
size_bytes=job.downloaded_bytes,
198+
local_path=str(dest_path),
199+
)
200+
db.add(entry)
201+
202+
db.commit()
203+
logger.info(f'Firmware entry saved to database: {dest_filename}')
204+
finally:
205+
db.close()
206+
except Exception as e:
207+
logger.error(f'Failed to save firmware entry to database: {e}')
208+
173209
except asyncio.CancelledError:
174210
job.status = 'cancelled'
175211
job.error = 'Download cancelled by user'

backend/db/models.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,25 @@ class OperationLog(Base):
189189

190190
device = relationship("Device", back_populates="logs")
191191
user = relationship("User", back_populates="logs")
192+
193+
194+
class FirmwareEntry(Base):
195+
"""Firmware metadata — persists across restarts for manual/cached firmware entries."""
196+
197+
__tablename__ = "firmware_entries"
198+
199+
id = Column(Integer, primary_key=True, index=True)
200+
source = Column(String(64), nullable=False, index=True) # "samfw" | "manual" | "auto"
201+
filename = Column(String(512), nullable=True, unique=True) # original zip/dir name
202+
device_model = Column(String(64), nullable=False, index=True) # e.g. "SM-A556B"
203+
sales_code = Column(String(8), nullable=False, index=True) # e.g. "XEU"
204+
ap_version = Column(String(128), nullable=True) # e.g. "A556BXXU3BXJ1"
205+
csc_version = Column(String(128), nullable=True)
206+
package_variant = Column(String(32), nullable=True) # "fac" etc.
207+
country = Column(String(10), nullable=True)
208+
language = Column(String(10), nullable=True)
209+
size_bytes = Column(BigInteger, default=0, nullable=False)
210+
local_path = Column(String(512), nullable=True)
211+
notes = Column(Text, nullable=True) # user notes
212+
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
213+
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

0 commit comments

Comments
 (0)