Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ab1da4bd5 | |||
| 1566e32a85 | |||
| fe7a047656 | |||
| 9c285b426f | |||
| c58705f35c | |||
| 8d874944b5 | |||
| b6f85b8e29 | |||
| 68693e7168 | |||
| 84c3bb6e4c | |||
| d8aec91599 | |||
| 11d3209482 | |||
| e19c2564f6 | |||
| 6c0ae6627b | |||
| 8928c75a9d | |||
| b09b485e80 | |||
| 9e9528054e | |||
| 12cbcb1a29 | |||
| 9b9a196f73 | |||
| 9ce3fbcb9e | |||
| 3065b80370 | |||
| 93acc58191 | |||
| d9f775562f | |||
| 85d957be2b | |||
| 7774fc4cc8 | |||
| a0b0ed0cd7 | |||
| 1e831f05db | |||
| 855300cca1 | |||
| 141fca27cf | |||
| 0ee540210a | |||
| 71c5b16d48 | |||
| 5ed1fc9ac0 | |||
| 42149012a1 | |||
| c050ec9fa3 | |||
| 3cd439e068 | |||
| 3430e56dfc | |||
| e75b004ebc | |||
| f3b62ed3a1 | |||
| ba5a52c5dc | |||
| 8366e0691d | |||
| 68906b2f28 | |||
| 5f7d3e71ae | |||
| 6b982b6730 | |||
| ef0c10ca6b | |||
| f121b8804c | |||
| bab6993e5b | |||
| 80303f7900 | |||
| 46ba537bec | |||
| e21b76ad7f | |||
| 5f69967c7a | |||
| 24954cb893 |
@@ -1 +1 @@
|
|||||||
ko_fi: dadaloop82
|
ko_fi: evershelfproject
|
||||||
|
|||||||
@@ -119,3 +119,63 @@ jobs:
|
|||||||
|
|
||||||
Triggered by: $LAST"
|
Triggered by: $LAST"
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|
||||||
|
# ── Auto-create GitHub Release on main ───────────────────────────────────
|
||||||
|
# Runs after auto-merge succeeds. Reads version from index.html,
|
||||||
|
# creates a release tag vX.Y.Z if it doesn't exist yet.
|
||||||
|
# This powers the in-app update badge for self-hosted users.
|
||||||
|
create-release:
|
||||||
|
name: Create GitHub Release
|
||||||
|
needs: [auto-merge-to-main]
|
||||||
|
if: github.ref == 'refs/heads/develop'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout main
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract version from index.html
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VER=$(grep -oP 'header-version">v\K[\d.]+' index.html | head -1)
|
||||||
|
echo "version=v${VER}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Detected version: v${VER}"
|
||||||
|
|
||||||
|
- name: Check if tag already exists
|
||||||
|
id: tag_check
|
||||||
|
run: |
|
||||||
|
if git ls-remote --tags origin "refs/tags/${{ steps.version.outputs.version }}" | grep -q .; then
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Read CHANGELOG entry for this version
|
||||||
|
id: changelog
|
||||||
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
|
run: |
|
||||||
|
VER="${{ steps.version.outputs.version }}"
|
||||||
|
# Extract the section for this version from CHANGELOG.md
|
||||||
|
BODY=$(awk "/^## \[?${VER#v}\]?|^## ${VER}/,/^## [0-9]/" CHANGELOG.md | head -50 | tail -n +1 | grep -v "^## [0-9]" || true)
|
||||||
|
if [ -z "$BODY" ]; then
|
||||||
|
BODY="See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details."
|
||||||
|
fi
|
||||||
|
# Multiline output
|
||||||
|
echo "body<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
echo "$BODY" >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.version }}
|
||||||
|
name: "EverShelf ${{ steps.version.outputs.version }}"
|
||||||
|
body: ${{ steps.changelog.outputs.body }}
|
||||||
|
target_commitish: main
|
||||||
|
make_latest: true
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ on:
|
|||||||
- 'Dockerfile'
|
- 'Dockerfile'
|
||||||
- 'docker-compose.yml'
|
- 'docker-compose.yml'
|
||||||
- 'api/**'
|
- 'api/**'
|
||||||
|
- '.github/workflows/security.yml'
|
||||||
schedule:
|
schedule:
|
||||||
# Run weekly on Monday at 07:00 UTC
|
# Run weekly on Monday at 07:00 UTC
|
||||||
- cron: '0 7 * * 1'
|
- cron: '0 7 * * 1'
|
||||||
@@ -27,16 +28,17 @@ jobs:
|
|||||||
run: docker build -t evershelf:scan .
|
run: docker build -t evershelf:scan .
|
||||||
|
|
||||||
- name: Run Trivy vulnerability scanner
|
- name: Run Trivy vulnerability scanner
|
||||||
uses: aquasecurity/trivy-action@master
|
uses: aquasecurity/trivy-action@v0.36.0
|
||||||
with:
|
with:
|
||||||
image-ref: 'evershelf:scan'
|
image-ref: 'evershelf:scan'
|
||||||
format: 'sarif'
|
format: 'sarif'
|
||||||
output: 'trivy-results.sarif'
|
output: 'trivy-results.sarif'
|
||||||
severity: 'CRITICAL,HIGH'
|
severity: 'CRITICAL,HIGH'
|
||||||
|
ignore-unfixed: true
|
||||||
exit-code: '0' # don't fail the build, just report
|
exit-code: '0' # don't fail the build, just report
|
||||||
|
|
||||||
- name: Upload Trivy SARIF to GitHub Security tab
|
- name: Upload Trivy SARIF to GitHub Security tab
|
||||||
uses: github/codeql-action/upload-sarif@v3
|
uses: github/codeql-action/upload-sarif@v4
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-results.sarif'
|
sarif_file: 'trivy-results.sarif'
|
||||||
category: 'trivy-docker'
|
category: 'trivy-docker'
|
||||||
@@ -52,17 +54,18 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Run Trivy filesystem scanner
|
- name: Run Trivy filesystem scanner
|
||||||
uses: aquasecurity/trivy-action@master
|
uses: aquasecurity/trivy-action@v0.36.0
|
||||||
with:
|
with:
|
||||||
scan-type: 'fs'
|
scan-type: 'fs'
|
||||||
scan-ref: '.'
|
scan-ref: '.'
|
||||||
format: 'sarif'
|
format: 'sarif'
|
||||||
output: 'trivy-fs-results.sarif'
|
output: 'trivy-fs-results.sarif'
|
||||||
severity: 'CRITICAL,HIGH'
|
severity: 'CRITICAL,HIGH'
|
||||||
|
ignore-unfixed: true
|
||||||
exit-code: '0'
|
exit-code: '0'
|
||||||
|
|
||||||
- name: Upload Trivy FS SARIF
|
- name: Upload Trivy FS SARIF
|
||||||
uses: github/codeql-action/upload-sarif@v3
|
uses: github/codeql-action/upload-sarif@v4
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-fs-results.sarif'
|
sarif_file: 'trivy-fs-results.sarif'
|
||||||
category: 'trivy-fs'
|
category: 'trivy-fs'
|
||||||
|
|||||||
@@ -49,3 +49,4 @@ evershelf-kiosk/local.properties
|
|||||||
data/error_reports.log
|
data/error_reports.log
|
||||||
data/latest_release_cache.json
|
data/latest_release_cache.json
|
||||||
data/food_facts_cache.json
|
data/food_facts_cache.json
|
||||||
|
data/category_ai_cache.json
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
FROM php:8.2-apache
|
FROM php:8.2-apache-bookworm
|
||||||
|
|
||||||
# Install required PHP extensions + Tesseract OCR for offline expiry date reading
|
# Install required PHP extensions + Tesseract OCR for offline expiry date reading
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
||||||
libsqlite3-dev \
|
libsqlite3-dev \
|
||||||
libcurl4-openssl-dev \
|
libcurl4-openssl-dev \
|
||||||
libonig-dev \
|
libonig-dev \
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
[](https://github.com/dadaloop82/EverShelf/discussions)
|
[](https://github.com/dadaloop82/EverShelf/discussions)
|
||||||
[](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml)
|
[](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml)
|
||||||
|
|
||||||
|
[](https://ko-fi.com/J3J01ZNETZ)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
@@ -102,7 +104,7 @@
|
|||||||
- **Stability + auto-confirm** — 10s stable wait + 5s countdown before confirming
|
- **Stability + auto-confirm** — 10s stable wait + 5s countdown before confirming
|
||||||
- **Real-time status** — Scale connection indicator always visible in the header
|
- **Real-time status** — Scale connection indicator always visible in the header
|
||||||
- **Multi-protocol** — Supports Bluetooth SIG Weight Scale, Body Composition, Xiaomi Mi Scale 2 and 100+ models
|
- **Multi-protocol** — Supports Bluetooth SIG Weight Scale, Body Composition, Xiaomi Mi Scale 2 and 100+ models
|
||||||
- **Built into kiosk (v1.6.0+)** — BLE gateway runs as an integrated foreground service inside the [EverShelf Kiosk](evershelf-kiosk/) app; no separate APK needed. The standalone gateway app in [`evershelf-scale-gateway/`](evershelf-scale-gateway/) is deprecated but kept for non-kiosk use cases.
|
- **Built into kiosk (v1.6.0+)** — BLE gateway runs as an integrated foreground service inside the [EverShelf Kiosk](evershelf-kiosk/) app; no separate APK needed.
|
||||||
|
|
||||||
### 📺 Android Kiosk Mode (Add-on)
|
### 📺 Android Kiosk Mode (Add-on)
|
||||||
- **Dedicated tablet app** — Full-screen WebView wrapper for wall-mounted kitchen tablets
|
- **Dedicated tablet app** — Full-screen WebView wrapper for wall-mounted kitchen tablets
|
||||||
@@ -350,10 +352,35 @@ The application uses no build tools — edit files directly and refresh.
|
|||||||
|
|
||||||
## 📋 Roadmap
|
## 📋 Roadmap
|
||||||
|
|
||||||
- [ ] User authentication / multi-user support
|
### High Priority
|
||||||
- [ ] Offline mode with service worker
|
- [ ] **Cooking mode — 3D wheel JS** — swipe navigation, gyroscope tilt, haptic feedback
|
||||||
- [ ] Export/import inventory data
|
- [ ] **Cooking mode — step timers** — auto-detect "X minutes" in recipe steps, countdown + alert
|
||||||
- [ ] Notification system (Telegram, email) for expiring products
|
- [ ] **Push notifications** — daily expiry alerts via PWA Service Worker + VAPID
|
||||||
|
- [ ] **Quick search / quick-add bar** — always-visible search above the nav, PWA shortcuts
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
- [ ] **Receipt OCR → bulk add** — photo of receipt → Gemini Vision → auto-fill inventory
|
||||||
|
- [ ] **CSV/JSON export & import** — download/upload inventory from Settings
|
||||||
|
- [ ] **Custom storage locations** — user-defined locations beyond Fridge/Freezer/Pantry
|
||||||
|
- [ ] **Multi-user support** — PIN-based user distinction, action log with user label
|
||||||
|
- [ ] **AI optimal purchase prediction** — suggest "buy X units of Y within Z days"
|
||||||
|
- [ ] **Price history sparklines** — per-product price chart from the AI cache data
|
||||||
|
|
||||||
|
### Low Priority / Nice to Have
|
||||||
|
- [ ] **Dark mode** — CSS custom properties are already structured to support it
|
||||||
|
- [ ] **Full offline mode** — Service Worker cache to show inventory read-only when server is down
|
||||||
|
- [ ] **French & Spanish translations** (`fr.json`, `es.json`)
|
||||||
|
- [ ] **Swipe actions on inventory rows** — swipe left to use/discard, right to edit
|
||||||
|
- [ ] **PHP unit tests** — PHPUnit coverage for shelf-life, price calc, and key helpers
|
||||||
|
|
||||||
|
### Completed ✅
|
||||||
|
- ✅ AI price estimation in shopping list
|
||||||
|
- ✅ Server heartbeat + offline banner
|
||||||
|
- ✅ In-app bug reporter → automatic GitHub issue creation
|
||||||
|
- ✅ Cooking mode (start, steps, 3D wheel CSS)
|
||||||
|
- ✅ Kiosk ⚙️ Settings overlay button (replaces Android native button)
|
||||||
|
- ✅ Adaptive consumption anomaly detection
|
||||||
|
- ✅ CI/CD pipeline (PHP lint, JS lint, Docker build, Trivy security scan)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -138,11 +138,14 @@ function migrateDB(PDO $db): void {
|
|||||||
quantity REAL NOT NULL,
|
quantity REAL NOT NULL,
|
||||||
location TEXT NOT NULL DEFAULT 'dispensa',
|
location TEXT NOT NULL DEFAULT 'dispensa',
|
||||||
notes TEXT DEFAULT '',
|
notes TEXT DEFAULT '',
|
||||||
|
undone INTEGER DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
");
|
");
|
||||||
$db->exec("INSERT INTO transactions SELECT * FROM transactions_old");
|
// Insert with explicit columns: transactions_old may lack 'undone' (pre-v1.7.x DB)
|
||||||
|
$db->exec("INSERT INTO transactions (id, product_id, type, quantity, location, notes, created_at)
|
||||||
|
SELECT id, product_id, type, quantity, location, notes, created_at FROM transactions_old");
|
||||||
$db->exec("DROP TABLE transactions_old");
|
$db->exec("DROP TABLE transactions_old");
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id)");
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at)");
|
||||||
@@ -360,6 +363,10 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
|||||||
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 2;
|
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 2;
|
||||||
if (preg_match('/\blatte\b/', $n)) return 1;
|
if (preg_match('/\blatte\b/', $n)) return 1;
|
||||||
if (preg_match('/\bformaggio\b/', $n)) return 2;
|
if (preg_match('/\bformaggio\b/', $n)) return 2;
|
||||||
|
// Root vegetables / tubers in pantry: sfusi in un sacchetto, durano 3-5 settimane
|
||||||
|
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 30;
|
||||||
|
if (preg_match('/\b(cipolla|cipolle|aglio|scalogno|porro)\b/', $n)) return 30;
|
||||||
|
if (preg_match('/\b(carota|carote)\b/', $n)) return 14;
|
||||||
return 60; // generic pantry fallback
|
return 60; // generic pantry fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +416,7 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
|||||||
if (preg_match('/\b(senape|mustard)\b/', $n)) return 90;
|
if (preg_match('/\b(senape|mustard)\b/', $n)) return 90;
|
||||||
if (preg_match('/salsa\s+di\s+soia|soy\s*sauce/', $n)) return 90;
|
if (preg_match('/salsa\s+di\s+soia|soy\s*sauce/', $n)) return 90;
|
||||||
if (preg_match('/\b(tabasco|worcestershire|sriracha)\b/', $n)) return 180;
|
if (preg_match('/\b(tabasco|worcestershire|sriracha)\b/', $n)) return 180;
|
||||||
if (preg_match('/confettura|marmellata/', $n)) return 60;
|
if (preg_match('/confettura|marmellata/', $n)) return 180;
|
||||||
if (preg_match('/nutella|cioccolat/', $n)) return 60;
|
if (preg_match('/nutella|cioccolat/', $n)) return 60;
|
||||||
|
|
||||||
// ── H: Category fallbacks ────────────────────────────────────────────
|
// ── H: Category fallbacks ────────────────────────────────────────────
|
||||||
@@ -467,7 +474,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
|
|||||||
elseif (preg_match('/carota|carote|zucchina|zucchine|peperoni|melanzane/', $n)) $days = 7;
|
elseif (preg_match('/carota|carote|zucchina|zucchine|peperoni|melanzane/', $n)) $days = 7;
|
||||||
elseif (preg_match('/broccoli|cavolfiore|cavolo|spinaci|bietola/', $n)) $days = 5;
|
elseif (preg_match('/broccoli|cavolfiore|cavolo|spinaci|bietola/', $n)) $days = 5;
|
||||||
elseif (preg_match('/cipolla|cipolle/', $n)) $days = 10;
|
elseif (preg_match('/cipolla|cipolle/', $n)) $days = 10;
|
||||||
elseif (preg_match('/patata|patate/', $n)) $days = 14;
|
elseif (preg_match('/patata|patate/', $n)) $days = 30; // whole tubers in a bag, pantry: 3-5 weeks
|
||||||
elseif (preg_match('/biscott|cracker|grissini|fette\s+biscott/', $n)) $days = 180;
|
elseif (preg_match('/biscott|cracker|grissini|fette\s+biscott/', $n)) $days = 180;
|
||||||
elseif (preg_match('/nutella|marmellata|miele/', $n)) $days = 365;
|
elseif (preg_match('/nutella|marmellata|miele/', $n)) $days = 365;
|
||||||
elseif (preg_match('/passata|pelati|pomodor/', $n)) $days = 730;
|
elseif (preg_match('/passata|pelati|pomodor/', $n)) $days = 730;
|
||||||
|
|||||||
@@ -3020,6 +3020,7 @@ PROMPT;
|
|||||||
'error_empty_reply' => 'Risposta vuota da Gemini',
|
'error_empty_reply' => 'Risposta vuota da Gemini',
|
||||||
'prompt_lang_rule' => 'IMPORTANTE: scrivi tutti i campi testuali della ricetta in Italiano.',
|
'prompt_lang_rule' => 'IMPORTANTE: scrivi tutti i campi testuali della ricetta in Italiano.',
|
||||||
'prompt_step_example' => 'Passo 1…',
|
'prompt_step_example' => 'Passo 1…',
|
||||||
|
'tools_title' => 'Strumenti necessari',
|
||||||
],
|
],
|
||||||
'en' => [
|
'en' => [
|
||||||
'status_analyze_pantry' => '📦 Analyzing pantry...',
|
'status_analyze_pantry' => '📦 Analyzing pantry...',
|
||||||
@@ -3042,6 +3043,7 @@ PROMPT;
|
|||||||
'error_empty_reply' => 'Empty response from Gemini',
|
'error_empty_reply' => 'Empty response from Gemini',
|
||||||
'prompt_lang_rule' => 'IMPORTANT: write all textual recipe fields in English only. Do not use Italian or German.',
|
'prompt_lang_rule' => 'IMPORTANT: write all textual recipe fields in English only. Do not use Italian or German.',
|
||||||
'prompt_step_example' => 'Step 1…',
|
'prompt_step_example' => 'Step 1…',
|
||||||
|
'tools_title' => 'Equipment needed',
|
||||||
],
|
],
|
||||||
'de' => [
|
'de' => [
|
||||||
'status_analyze_pantry' => '📦 Vorrat wird analysiert...',
|
'status_analyze_pantry' => '📦 Vorrat wird analysiert...',
|
||||||
@@ -3064,6 +3066,7 @@ PROMPT;
|
|||||||
'error_empty_reply' => 'Leere Antwort von Gemini',
|
'error_empty_reply' => 'Leere Antwort von Gemini',
|
||||||
'prompt_lang_rule' => 'WICHTIG: schreibe alle textuellen Rezeptfelder nur auf Deutsch. Verwende kein Italienisch oder Englisch.',
|
'prompt_lang_rule' => 'WICHTIG: schreibe alle textuellen Rezeptfelder nur auf Deutsch. Verwende kein Italienisch oder Englisch.',
|
||||||
'prompt_step_example' => 'Schritt 1…',
|
'prompt_step_example' => 'Schritt 1…',
|
||||||
|
'tools_title' => 'Benötigte Geräte',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
$text = $dict[$lang][$key] ?? $dict['it'][$key] ?? $key;
|
$text = $dict[$lang][$key] ?? $dict['it'][$key] ?? $key;
|
||||||
@@ -3441,14 +3444,15 @@ REGOLE:
|
|||||||
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. IMPORTANTE: per ingredienti con unità "pz" scrivi qty_number come numero di PEZZI (es. 2, non 200g).
|
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. IMPORTANTE: per ingredienti con unità "pz" scrivi qty_number come numero di PEZZI (es. 2, non 200g).
|
||||||
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
|
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
|
||||||
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
||||||
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). Keep `meal` unchanged.
|
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
|
||||||
|
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
|
||||||
|
|
||||||
DISPENSA:
|
DISPENSA:
|
||||||
$ingredientsText
|
$ingredientsText
|
||||||
|
|
||||||
Rispondi SOLO JSON valido (no markdown):
|
Rispondi SOLO JSON valido (no markdown):
|
||||||
{$promptLanguageRule}
|
{$promptLanguageRule}
|
||||||
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"}
|
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","tools_needed":["…"],"ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"}
|
||||||
PROMPT;
|
PROMPT;
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
@@ -4317,14 +4321,15 @@ REGOLE:
|
|||||||
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. IMPORTANTE: per ingredienti con unità "pz" scrivi qty_number come numero di PEZZI (es. 2, non 200g).
|
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. IMPORTANTE: per ingredienti con unità "pz" scrivi qty_number come numero di PEZZI (es. 2, non 200g).
|
||||||
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
|
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
|
||||||
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
||||||
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). Keep `meal` unchanged.
|
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
|
||||||
|
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
|
||||||
|
|
||||||
DISPENSA:
|
DISPENSA:
|
||||||
$ingredientsText
|
$ingredientsText
|
||||||
|
|
||||||
Rispondi SOLO JSON valido (no markdown):
|
Rispondi SOLO JSON valido (no markdown):
|
||||||
{$promptLanguageRule}
|
{$promptLanguageRule}
|
||||||
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"}
|
{"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","tools_needed":["…"],"ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"}
|
||||||
PROMPT;
|
PROMPT;
|
||||||
|
|
||||||
$genConfig = [
|
$genConfig = [
|
||||||
|
|||||||
@@ -3918,6 +3918,29 @@ body.server-offline .bottom-nav {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recipe-tools-banner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: #f0f4ff;
|
||||||
|
border: 1px solid #c7d2fe;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #3730a3;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.recipe-tool-chip {
|
||||||
|
background: #e0e7ff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #3730a3;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Recipe ingredient use buttons */
|
/* Recipe ingredient use buttons */
|
||||||
.recipe-ingredients {
|
.recipe-ingredients {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -4294,14 +4317,153 @@ body.cooking-mode-active .app-header {
|
|||||||
transform: scale(1.35);
|
transform: scale(1.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cooking-step-text {
|
.cooking-wheel {
|
||||||
font-size: clamp(1.4rem, 5vw, 2.2rem);
|
position: relative;
|
||||||
line-height: 1.5;
|
--wheel-tilt-x: 0deg;
|
||||||
font-weight: 500;
|
--wheel-tilt-y: 0deg;
|
||||||
color: #fff;
|
--wheel-glow: 0.45;
|
||||||
|
width: min(90vw, 680px);
|
||||||
|
max-width: 680px;
|
||||||
|
min-height: clamp(240px, 36vh, 340px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
perspective: 1100px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: radial-gradient(circle at 50% 50%, rgba(255,255,255,0.07), rgba(255,255,255,0.02) 58%, transparent 100%);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
box-shadow: inset 0 0 48px rgba(0,0,0,0.35);
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-wheel::before,
|
||||||
|
.cooking-wheel::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-wheel::before {
|
||||||
|
background: radial-gradient(circle at 50% 50%, rgba(56, 189, 248, 0.18), rgba(251, 191, 36, 0.08) 36%, transparent 72%);
|
||||||
|
opacity: var(--wheel-glow);
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-wheel::after {
|
||||||
|
background: linear-gradient(180deg, rgba(0,0,0,0.34) 0%, transparent 20%, transparent 80%, rgba(0,0,0,0.34) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-text,
|
||||||
|
.cooking-step-ghost {
|
||||||
|
width: min(88vw, 620px);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 560px;
|
line-height: 1.5;
|
||||||
width: 100%;
|
padding: 18px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.22s ease, opacity 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-text {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
font-size: clamp(1.35rem, 4.6vw, 2.1rem);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0.06));
|
||||||
|
border: 1px solid rgba(255,255,255,0.20);
|
||||||
|
box-shadow: 0 18px 35px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.24);
|
||||||
|
transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y));
|
||||||
|
animation: cookingCardFloat 4.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-ghost {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
transform-origin: center center;
|
||||||
|
font-size: clamp(1.08rem, 3.9vw, 1.52rem);
|
||||||
|
font-weight: 560;
|
||||||
|
color: rgba(255,255,255,0.95);
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.18), rgba(255,255,255,0.08));
|
||||||
|
border: 1px solid rgba(255,255,255,0.22);
|
||||||
|
box-shadow: 0 12px 28px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.22);
|
||||||
|
text-shadow: 0 1px 2px rgba(0,0,0,0.34);
|
||||||
|
opacity: 0.66;
|
||||||
|
max-height: 42%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-prev {
|
||||||
|
color: rgba(255, 243, 210, 0.97);
|
||||||
|
background: linear-gradient(180deg, rgba(251, 191, 36, 0.24), rgba(251, 191, 36, 0.10));
|
||||||
|
border-color: rgba(251, 191, 36, 0.36);
|
||||||
|
transform: translateX(-50%) rotateX(56deg) rotateY(calc(var(--wheel-tilt-y) * 0.28)) scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-next {
|
||||||
|
color: rgba(220, 243, 255, 0.97);
|
||||||
|
background: linear-gradient(180deg, rgba(56, 189, 248, 0.24), rgba(56, 189, 248, 0.10));
|
||||||
|
border-color: rgba(56, 189, 248, 0.36);
|
||||||
|
transform: translateX(-50%) rotateX(-56deg) rotateY(calc(var(--wheel-tilt-y) * 0.28)) scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-ghost.is-empty {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-wheel.turn-next .cooking-step-text {
|
||||||
|
animation: cookingWheelCenterNext 0.34s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-wheel.turn-prev .cooking-step-text {
|
||||||
|
animation: cookingWheelCenterPrev 0.34s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-wheel.turn-next .cooking-step-prev {
|
||||||
|
animation: cookingWheelGhostNext 0.34s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-wheel.turn-prev .cooking-step-next {
|
||||||
|
animation: cookingWheelGhostPrev 0.34s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-wheel.snap .cooking-step-text {
|
||||||
|
animation: cookingWheelSnap 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cookingWheelCenterNext {
|
||||||
|
from { transform: translateY(20px) rotateX(-10deg); opacity: 0.75; }
|
||||||
|
to { transform: translateY(0) rotateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cookingWheelCenterPrev {
|
||||||
|
from { transform: translateY(-20px) rotateX(10deg); opacity: 0.75; }
|
||||||
|
to { transform: translateY(0) rotateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cookingWheelGhostNext {
|
||||||
|
from { opacity: 0.1; transform: translateX(-50%) rotateX(68deg) scale(0.84); }
|
||||||
|
to { opacity: 0.66; transform: translateX(-50%) rotateX(56deg) scale(0.9); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cookingWheelGhostPrev {
|
||||||
|
from { opacity: 0.1; transform: translateX(-50%) rotateX(-68deg) scale(0.84); }
|
||||||
|
to { opacity: 0.66; transform: translateX(-50%) rotateX(-56deg) scale(0.9); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cookingCardFloat {
|
||||||
|
0%, 100% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) translateY(0); }
|
||||||
|
50% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) translateY(-3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cookingWheelSnap {
|
||||||
|
0% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(0.97); }
|
||||||
|
70% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(1.018); }
|
||||||
|
100% { transform: rotateX(var(--wheel-tilt-x)) rotateY(var(--wheel-tilt-y)) scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.cooking-replay-btn {
|
.cooking-replay-btn {
|
||||||
@@ -4341,6 +4503,28 @@ body.cooking-mode-active .app-header {
|
|||||||
}
|
}
|
||||||
.cooking-timers-bar::-webkit-scrollbar { display: none; }
|
.cooking-timers-bar::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.cooking-tools-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
background: rgba(99,102,241,0.15);
|
||||||
|
border-bottom: 1px solid rgba(99,102,241,0.25);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #c7d2fe;
|
||||||
|
}
|
||||||
|
.cooking-tool-chip {
|
||||||
|
background: rgba(99,102,241,0.25);
|
||||||
|
border: 1px solid rgba(99,102,241,0.4);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: #e0e7ff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.cooking-timer-card {
|
.cooking-timer-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -4576,6 +4760,32 @@ body.cooking-mode-active .app-header {
|
|||||||
background: rgba(255,255,255,0.2);
|
background: rgba(255,255,255,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cooking-wheel {
|
||||||
|
min-height: clamp(210px, 34vh, 300px);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooking-step-text,
|
||||||
|
.cooking-step-ghost {
|
||||||
|
padding: 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.cooking-step-text,
|
||||||
|
.cooking-step-ghost,
|
||||||
|
.cooking-wheel.turn-next .cooking-step-text,
|
||||||
|
.cooking-wheel.turn-prev .cooking-step-text,
|
||||||
|
.cooking-wheel.turn-next .cooking-step-prev,
|
||||||
|
.cooking-wheel.turn-prev .cooking-step-next,
|
||||||
|
.cooking-wheel.snap .cooking-step-text {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Cooking button in recipe dialog */
|
/* Cooking button in recipe dialog */
|
||||||
.btn-cooking {
|
.btn-cooking {
|
||||||
background: linear-gradient(135deg, #1e3a5f, #2d5016);
|
background: linear-gradient(135deg, #1e3a5f, #2d5016);
|
||||||
|
|||||||
@@ -1553,7 +1553,7 @@ function estimateExpiryDays(product, location) {
|
|||||||
else if (/carota|carote|zucchina|zucchine|peperoni|melanzane/.test(name)) days = 7;
|
else if (/carota|carote|zucchina|zucchine|peperoni|melanzane/.test(name)) days = 7;
|
||||||
else if (/broccoli|cavolfiore|cavolo|spinaci|bietola/.test(name)) days = 5;
|
else if (/broccoli|cavolfiore|cavolo|spinaci|bietola/.test(name)) days = 5;
|
||||||
else if (/cipolla|cipolle/.test(name)) days = 10;
|
else if (/cipolla|cipolle/.test(name)) days = 10;
|
||||||
else if (/patata|patate/.test(name)) days = 14;
|
else if (/patata|patate/.test(name)) days = 30; // whole tubers in a bag, pantry: 3-5 weeks
|
||||||
else if (/biscott|cracker|grissini|fette\s+biscott/.test(name)) days = 180;
|
else if (/biscott|cracker|grissini|fette\s+biscott/.test(name)) days = 180;
|
||||||
else if (/nutella|marmellata|miele/.test(name)) days = 365;
|
else if (/nutella|marmellata|miele/.test(name)) days = 365;
|
||||||
else if (/passata|pelati|pomodor/.test(name)) days = 730;
|
else if (/passata|pelati|pomodor/.test(name)) days = 730;
|
||||||
@@ -1573,7 +1573,7 @@ function estimateExpiryDays(product, location) {
|
|||||||
else if (/arancia|arance|agrumi|mandarini|limone|limoni/.test(name)) days = Math.max(days, 21);
|
else if (/arancia|arance|agrumi|mandarini|limone|limoni/.test(name)) days = Math.max(days, 21);
|
||||||
else if (/carota|carote/.test(name)) days = Math.max(days, 21);
|
else if (/carota|carote/.test(name)) days = Math.max(days, 21);
|
||||||
else if (/cipolla/.test(name)) days = Math.max(days, 14);
|
else if (/cipolla/.test(name)) days = Math.max(days, 14);
|
||||||
else if (/patata|patate/.test(name)) days = Math.max(days, 21);
|
else if (/patata|patate/.test(name)) days = Math.max(days, 30);
|
||||||
else if (/pera|pere/.test(name)) days = Math.max(days, 21);
|
else if (/pera|pere/.test(name)) days = Math.max(days, 21);
|
||||||
else if (/kiwi/.test(name)) days = Math.max(days, 28);
|
else if (/kiwi/.test(name)) days = Math.max(days, 28);
|
||||||
else if (/uva/.test(name)) days = Math.max(days, 14);
|
else if (/uva/.test(name)) days = Math.max(days, 14);
|
||||||
@@ -1704,7 +1704,7 @@ function estimateOpenedExpiryDays(product, location) {
|
|||||||
if (/\b(senape|mustard)\b/.test(name)) return 90;
|
if (/\b(senape|mustard)\b/.test(name)) return 90;
|
||||||
if (/salsa\s+di\s+soia|soy\s*sauce/.test(name)) return 90;
|
if (/salsa\s+di\s+soia|soy\s*sauce/.test(name)) return 90;
|
||||||
if (/\b(tabasco|worcestershire|sriracha)\b/.test(name)) return 180;
|
if (/\b(tabasco|worcestershire|sriracha)\b/.test(name)) return 180;
|
||||||
if (/confettura|marmellata/.test(name)) return 60;
|
if (/confettura|marmellata/.test(name)) return 180;
|
||||||
if (/nutella|cioccolat/.test(name)) return 60;
|
if (/nutella|cioccolat/.test(name)) return 60;
|
||||||
|
|
||||||
// ── H: Category fallbacks ────────────────────────────────────────────
|
// ── H: Category fallbacks ────────────────────────────────────────────
|
||||||
@@ -2499,9 +2499,24 @@ function _injectKioskOverlay() {
|
|||||||
_kioskBridge.hardReload();
|
_kioskBridge.hardReload();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Settings button — replaces the native Android settings button
|
||||||
|
const settBtn = document.createElement('button');
|
||||||
|
settBtn.id = '_kiosk_settings_btn';
|
||||||
|
settBtn.textContent = '\u2699\uFE0F';
|
||||||
|
settBtn.title = t('settings.title') || 'Settings';
|
||||||
|
settBtn.style.cssText = btnStyle.replace('font-size:15px', 'font-size:16px');
|
||||||
|
settBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showPage('settings');
|
||||||
|
});
|
||||||
|
|
||||||
wrap.appendChild(exitBtn);
|
wrap.appendChild(exitBtn);
|
||||||
wrap.appendChild(refBtn);
|
wrap.appendChild(refBtn);
|
||||||
|
wrap.appendChild(settBtn);
|
||||||
headerLeft.appendChild(wrap);
|
headerLeft.appendChild(wrap);
|
||||||
|
|
||||||
|
// Permanently hide the native Android settings button — replaced by the web overlay button above.
|
||||||
|
try { _kioskBridge.setNativeSettingsVisible(false); } catch(_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAppliances(appliances) {
|
function renderAppliances(appliances) {
|
||||||
@@ -3604,8 +3619,16 @@ async function loadDashboard() {
|
|||||||
if (days !== null && days !== undefined) {
|
if (days !== null && days !== undefined) {
|
||||||
let expiryClass, expiryText;
|
let expiryClass, expiryText;
|
||||||
if (!isEdible) {
|
if (!isEdible) {
|
||||||
expiryClass = 'opened-expiry-spoiled';
|
// Only show the red ⛔ badge for items that are genuinely dangerous.
|
||||||
expiryText = t('expiry.badge_expired');
|
// For conserve/condiments classified as safe, use a gentler amber badge.
|
||||||
|
const spoiledSafety = getExpiredSafety(item, Math.abs(item.days_to_expiry ?? 1));
|
||||||
|
if (spoiledSafety.level === 'ok') {
|
||||||
|
expiryClass = 'opened-expiry-soon';
|
||||||
|
expiryText = '\u26A0\uFE0F ' + t('expiry.badge_check_soon');
|
||||||
|
} else {
|
||||||
|
expiryClass = 'opened-expiry-spoiled';
|
||||||
|
expiryText = t('expiry.badge_expired');
|
||||||
|
}
|
||||||
} else if (days > 365) {
|
} else if (days > 365) {
|
||||||
expiryClass = 'opened-expiry-ok';
|
expiryClass = 'opened-expiry-ok';
|
||||||
expiryText = t('expiry.badge_stable');
|
expiryText = t('expiry.badge_stable');
|
||||||
@@ -4887,8 +4910,8 @@ function showItemDetail(inventoryId, productId) {
|
|||||||
function closeModal() {
|
function closeModal() {
|
||||||
document.getElementById('modal-overlay').style.display = 'none';
|
document.getElementById('modal-overlay').style.display = 'none';
|
||||||
clearMoveModalTimer();
|
clearMoveModalTimer();
|
||||||
// Restore native kiosk settings button visibility
|
// Native kiosk settings button is permanently replaced by the web overlay button — keep hidden.
|
||||||
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(true); } catch (_) {}
|
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(false); } catch (_) {}
|
||||||
_cancelScaleAutoConfirm(false);
|
_cancelScaleAutoConfirm(false);
|
||||||
_scaleRecipeAutoFillPaused = false;
|
_scaleRecipeAutoFillPaused = false;
|
||||||
_scaleUserDismissed = false;
|
_scaleUserDismissed = false;
|
||||||
@@ -7603,6 +7626,9 @@ async function loadUseInventoryInfo() {
|
|||||||
// Build location buttons only for locations where the product exists
|
// Build location buttons only for locations where the product exists
|
||||||
const productLocations = [...new Set(items.map(i => i.location))];
|
const productLocations = [...new Set(items.map(i => i.location))];
|
||||||
const locSelector = document.getElementById('use-location-selector');
|
const locSelector = document.getElementById('use-location-selector');
|
||||||
|
// Hide the location row when the product is in only one location (nothing to choose)
|
||||||
|
const locGroup = document.getElementById('use-location-group');
|
||||||
|
if (locGroup) locGroup.style.display = productLocations.length > 1 ? '' : 'none';
|
||||||
|
|
||||||
// Prefer the remembered location (if confirmed), else use the opened-package heuristic
|
// Prefer the remembered location (if confirmed), else use the opened-package heuristic
|
||||||
const prefLoc = _getPreferredUseLocation(currentProduct.id);
|
const prefLoc = _getPreferredUseLocation(currentProduct.id);
|
||||||
@@ -7801,6 +7827,42 @@ function selectUseLocation(btn, loc) {
|
|||||||
const _PREF_LOC_KEY = '_prefUseLoc';
|
const _PREF_LOC_KEY = '_prefUseLoc';
|
||||||
const _PREF_LOC_NEEDED = 2; // choices needed to confirm a preference
|
const _PREF_LOC_NEEDED = 2; // choices needed to confirm a preference
|
||||||
|
|
||||||
|
// ── PREFERRED MOVE-AFTER-USE LOCATION ────────────────────────────────────
|
||||||
|
// Tracks where the user puts the remainder after using a product.
|
||||||
|
// After _PREF_MOVE_NEEDED consistent choices, the modal is skipped entirely.
|
||||||
|
const _PREF_MOVE_KEY = '_prefMoveLoc';
|
||||||
|
const _PREF_MOVE_NEEDED = 2;
|
||||||
|
let _pendingMoveCtx = null; // { productId, fromLoc, openedId } — set before showing modal
|
||||||
|
|
||||||
|
function _getMoveLocHistory(productId, fromLoc) {
|
||||||
|
try {
|
||||||
|
const all = JSON.parse(localStorage.getItem(_PREF_MOVE_KEY) || '{}');
|
||||||
|
return all[`${productId}|${fromLoc}`] || [];
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _recordMoveLocChoice(productId, fromLoc, toLoc) {
|
||||||
|
try {
|
||||||
|
const all = JSON.parse(localStorage.getItem(_PREF_MOVE_KEY) || '{}');
|
||||||
|
const key = `${productId}|${fromLoc}`;
|
||||||
|
const hist = all[key] || [];
|
||||||
|
hist.push(toLoc);
|
||||||
|
if (hist.length > 8) hist.splice(0, hist.length - 8);
|
||||||
|
all[key] = hist;
|
||||||
|
localStorage.setItem(_PREF_MOVE_KEY, JSON.stringify(all));
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getPreferredMoveLoc(productId, fromLoc) {
|
||||||
|
const hist = _getMoveLocHistory(productId, fromLoc);
|
||||||
|
if (hist.length < _PREF_MOVE_NEEDED) return null;
|
||||||
|
const recent = hist.slice(-5);
|
||||||
|
const counts = {};
|
||||||
|
for (const loc of recent) counts[loc] = (counts[loc] || 0) + 1;
|
||||||
|
const [topLoc, topCount] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
|
||||||
|
return topCount >= _PREF_MOVE_NEEDED ? topLoc : null;
|
||||||
|
}
|
||||||
|
|
||||||
function _getPrefLocHistory(productId) {
|
function _getPrefLocHistory(productId) {
|
||||||
try {
|
try {
|
||||||
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
|
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
|
||||||
@@ -8153,6 +8215,22 @@ function startMoveModalCountdown(btnId, onExpire) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacuumSealed, unit) {
|
function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacuumSealed, unit) {
|
||||||
|
// Store context so _saveVacuumAndStay can record the choice
|
||||||
|
_pendingMoveCtx = { productId: product.id, fromLoc, openedId };
|
||||||
|
|
||||||
|
// If a preference is established, skip the modal entirely and auto-apply
|
||||||
|
const prefMoveLoc = _getPreferredMoveLoc(product.id, fromLoc);
|
||||||
|
if (prefMoveLoc) {
|
||||||
|
if (prefMoveLoc === fromLoc) {
|
||||||
|
// Preference: stay in place — silent, no modal
|
||||||
|
_saveVacuumAndStay(openedId || 0);
|
||||||
|
} else {
|
||||||
|
// Preference: move to another location — apply silently
|
||||||
|
confirmMoveAfterUse(product.id, fromLoc, prefMoveLoc, openedId || 0, !!(openedVacuumSealed ?? product.vacuum_sealed));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc);
|
const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc);
|
||||||
const locButtons = otherLocs.map(([k, v]) =>
|
const locButtons = otherLocs.map(([k, v]) =>
|
||||||
`<button type="button" class="loc-btn" onclick="clearMoveModalTimer();confirmMoveAfterUse(${product.id}, '${fromLoc}', '${k}', ${openedId || 0})">${v.icon} ${v.label}</button>`
|
`<button type="button" class="loc-btn" onclick="clearMoveModalTimer();confirmMoveAfterUse(${product.id}, '${fromLoc}', '${k}', ${openedId || 0})">${v.icon} ${v.label}</button>`
|
||||||
@@ -8186,6 +8264,11 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu
|
|||||||
|
|
||||||
/** Save vacuum state when user chooses to keep the item at the current location. */
|
/** Save vacuum state when user chooses to keep the item at the current location. */
|
||||||
async function _saveVacuumAndStay(openedId) {
|
async function _saveVacuumAndStay(openedId) {
|
||||||
|
// Record the "stay" preference before closing
|
||||||
|
if (_pendingMoveCtx) {
|
||||||
|
_recordMoveLocChoice(_pendingMoveCtx.productId, _pendingMoveCtx.fromLoc, _pendingMoveCtx.fromLoc);
|
||||||
|
_pendingMoveCtx = null;
|
||||||
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
if (openedId) {
|
if (openedId) {
|
||||||
const isVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0;
|
const isVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0;
|
||||||
@@ -8197,9 +8280,14 @@ async function _saveVacuumAndStay(openedId) {
|
|||||||
showPage('dashboard');
|
showPage('dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId) {
|
async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId, forcedVacuum) {
|
||||||
clearMoveModalTimer();
|
clearMoveModalTimer();
|
||||||
const newVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0;
|
const newVacuum = forcedVacuum !== undefined ? (forcedVacuum ? 1 : 0) : (document.getElementById('move-vacuum-check')?.checked ? 1 : 0);
|
||||||
|
// Record preference
|
||||||
|
if (_pendingMoveCtx && _pendingMoveCtx.productId === productId) {
|
||||||
|
_recordMoveLocChoice(productId, fromLoc, toLoc);
|
||||||
|
_pendingMoveCtx = null;
|
||||||
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -11849,6 +11937,36 @@ async function confirmRecipeMove(productId, fromLoc, toLoc, openedId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract tools/appliances from recipe steps text when tools_needed is absent (old cached recipes).
|
||||||
|
* Returns an array of localised tool names found in the steps.
|
||||||
|
*/
|
||||||
|
function _extractToolsFromSteps(steps) {
|
||||||
|
const text = (steps || []).join(' ').toLowerCase();
|
||||||
|
// Map: regex keyword → display name per language
|
||||||
|
const patterns = [
|
||||||
|
{ re: /\bforn[oi]\b|oven|backofen/, it: 'Forno', en: 'Oven', de: 'Backofen' },
|
||||||
|
{ re: /\bmicroond[ea]\b|microwave|mikrowelle/, it: 'Microonde', en: 'Microwave', de: 'Mikrowelle' },
|
||||||
|
{ re: /\bfrullator[ei]\b|blender|mixer\b|pimer|frullatore a immersione|stabmixer/,
|
||||||
|
it: 'Frullatore', en: 'Blender', de: 'Mixer' },
|
||||||
|
{ re: /\bfritteuse\b|friggitrici[ae]\b|air\s*fry|friggitric[ae]\b|friggi\b/, it: 'Friggitrice', en: 'Air fryer', de: 'Fritteuse' },
|
||||||
|
{ re: /\bpentola\s+a\s+pressione\b|pressure\s+cook|schnellkochtopf|cookeo|instant\s*pot/, it: 'Pentola a pressione', en: 'Pressure cooker', de: 'Schnellkochtopf' },
|
||||||
|
{ re: /\bbimby\b|thermomix\b|monsieur\s+cuisine/,it: 'Bimby/Thermomix', en: 'Thermomix', de: 'Thermomix' },
|
||||||
|
{ re: /\bimpastatric[ae]\b|planetari[ao]\b|stand\s*mixer|knetmaschine/, it: 'Impastatrice', en: 'Stand mixer', de: 'Knetmaschine' },
|
||||||
|
{ re: /\bvapore\b|steamer\b|dampfgarer\b/, it: 'Vaporiera', en: 'Steamer', de: 'Dampfgarer' },
|
||||||
|
{ re: /\bslow\s*cook|cottura\s+lenta\b|schongarer/, it: 'Slow cooker', en: 'Slow cooker', de: 'Schongarer' },
|
||||||
|
{ re: /\bgrill[eo]?\b|griglia\b|grillpfanne/, it: 'Griglia', en: 'Grill', de: 'Grill' },
|
||||||
|
{ re: /\bmacchina\s+del\s+pane\b|bread\s*machine|brotbackautomat/, it: 'Macchina del pane', en: 'Bread machine', de: 'Brotbackautomat' },
|
||||||
|
{ re: /\bessiccator[ei]\b|dehydrator\b|dörrgerät/, it: 'Essiccatore', en: 'Dehydrator', de: 'Dörrgerät' },
|
||||||
|
];
|
||||||
|
const lang = _currentLang || 'it';
|
||||||
|
const found = [];
|
||||||
|
for (const p of patterns) {
|
||||||
|
if (p.re.test(text)) found.push(p[lang] || p.it);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
function renderRecipe(r) {
|
function renderRecipe(r) {
|
||||||
let html = `<h2>${r.title}</h2>`;
|
let html = `<h2>${r.title}</h2>`;
|
||||||
|
|
||||||
@@ -11866,6 +11984,14 @@ function renderRecipe(r) {
|
|||||||
html += `<div class="recipe-expiry-note">⚠️ ${r.expiry_note}</div>`;
|
html += `<div class="recipe-expiry-note">⚠️ ${r.expiry_note}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tools/appliances banner (shown only when specific equipment is needed)
|
||||||
|
const tools = (r.tools_needed && r.tools_needed.length > 0)
|
||||||
|
? r.tools_needed.filter(t => t && t.trim())
|
||||||
|
: _extractToolsFromSteps(r.steps);
|
||||||
|
if (tools.length > 0) {
|
||||||
|
html += `<div class="recipe-tools-banner">🔧 <strong>${t('recipes.tools_title')}:</strong> ${tools.map(t => `<span class="recipe-tool-chip">${t}</span>`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Ingredients
|
// Ingredients
|
||||||
html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`;
|
html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`;
|
||||||
(r.ingredients || []).forEach((ing, idx) => {
|
(r.ingredients || []).forEach((ing, idx) => {
|
||||||
@@ -12001,8 +12127,25 @@ function startCookingMode() {
|
|||||||
_cookingTTS = true;
|
_cookingTTS = true;
|
||||||
document.getElementById('cooking-title').textContent = _cookingRecipe.title || '';
|
document.getElementById('cooking-title').textContent = _cookingRecipe.title || '';
|
||||||
document.getElementById('cooking-tts-btn').textContent = '🔊';
|
document.getElementById('cooking-tts-btn').textContent = '🔊';
|
||||||
|
// Tools bar
|
||||||
|
const toolsBar = document.getElementById('cooking-tools-bar');
|
||||||
|
if (toolsBar) {
|
||||||
|
const tools = (_cookingRecipe.tools_needed && _cookingRecipe.tools_needed.length > 0)
|
||||||
|
? _cookingRecipe.tools_needed.filter(t => t && t.trim())
|
||||||
|
: _extractToolsFromSteps(_cookingRecipe.steps);
|
||||||
|
if (tools.length > 0) {
|
||||||
|
toolsBar.innerHTML = '🔧 ' + tools.map(t => `<span class="cooking-tool-chip">${t}</span>`).join('');
|
||||||
|
toolsBar.style.display = '';
|
||||||
|
} else {
|
||||||
|
toolsBar.style.display = 'none';
|
||||||
|
toolsBar.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
document.getElementById('cooking-overlay').style.display = 'flex';
|
document.getElementById('cooking-overlay').style.display = 'flex';
|
||||||
document.body.classList.add('cooking-mode-active');
|
document.body.classList.add('cooking-mode-active');
|
||||||
|
// Hide kiosk overlay — it lives outside <body> with z-index:2147483647 and would overlap cooking UI
|
||||||
|
const _kioskOvl = document.getElementById('_kiosk_overlay');
|
||||||
|
if (_kioskOvl) _kioskOvl.style.display = 'none';
|
||||||
_bindCookingWheelControls();
|
_bindCookingWheelControls();
|
||||||
const wheelEl = document.getElementById('cooking-wheel');
|
const wheelEl = document.getElementById('cooking-wheel');
|
||||||
if (wheelEl) setTimeout(() => wheelEl.focus(), 20);
|
if (wheelEl) setTimeout(() => wheelEl.focus(), 20);
|
||||||
@@ -12016,6 +12159,9 @@ function startCookingMode() {
|
|||||||
function closeCookingMode() {
|
function closeCookingMode() {
|
||||||
document.getElementById('cooking-overlay').style.display = 'none';
|
document.getElementById('cooking-overlay').style.display = 'none';
|
||||||
document.body.classList.remove('cooking-mode-active');
|
document.body.classList.remove('cooking-mode-active');
|
||||||
|
// Restore kiosk overlay
|
||||||
|
const _kioskOvl = document.getElementById('_kiosk_overlay');
|
||||||
|
if (_kioskOvl) _kioskOvl.style.display = 'flex';
|
||||||
// NOTE: intentionally keep _cookingRecipe, _cookingStep, _cookingVisited
|
// NOTE: intentionally keep _cookingRecipe, _cookingStep, _cookingVisited
|
||||||
// so the user can resume from the same step when they reopen
|
// so the user can resume from the same step when they reopen
|
||||||
try { screen.orientation?.unlock().catch(() => {}); } catch (_) { /* ignore */ }
|
try { screen.orientation?.unlock().catch(() => {}); } catch (_) { /* ignore */ }
|
||||||
@@ -12348,7 +12494,10 @@ function _initBrowserTtsVoices(selectedVoice) {
|
|||||||
sel.innerHTML = '<option value="">— Caricamento voci… —</option>';
|
sel.innerHTML = '<option value="">— Caricamento voci… —</option>';
|
||||||
|
|
||||||
const populate = () => {
|
const populate = () => {
|
||||||
const voices = window.speechSynthesis.getVoices();
|
let voices = [];
|
||||||
|
try {
|
||||||
|
voices = (window.speechSynthesis.getVoices() || []).filter(v => v != null && v.lang);
|
||||||
|
} catch (_) { return false; }
|
||||||
if (!voices.length) return false;
|
if (!voices.length) return false;
|
||||||
// Italian voices first, then others
|
// Italian voices first, then others
|
||||||
const it = voices.filter(v => v.lang.startsWith('it'));
|
const it = voices.filter(v => v.lang.startsWith('it'));
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ Overview of what the wizard will configure.
|
|||||||
### Step 3 — Permissions
|
### Step 3 — Permissions
|
||||||
Grant camera, microphone, and storage permissions needed by the web app.
|
Grant camera, microphone, and storage permissions needed by the web app.
|
||||||
|
|
||||||
The button transforms from "Concedi permessi" to **"✅ Permessi concessi — Continua →"** (green) once all permissions are granted.
|
The button transforms from **"Grant permissions"** to **"✅ Permissions granted — Continue →"** (green) once all permissions are granted.
|
||||||
|
|
||||||
### Step 4 — Server URL
|
### Step 4 — Server URL
|
||||||
Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`).
|
Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`).
|
||||||
|
|
||||||
**Or tap "Rileva automaticamente"** to let the wizard scan your LAN:
|
**Or tap "Auto-discover"** to let the wizard scan your LAN:
|
||||||
- 60 parallel threads, TCP pre-check, ports 80/443/8080/8443
|
- 60 parallel threads, TCP pre-check, ports 80/443/8080/8443
|
||||||
- Only scans your actual Wi-Fi/Ethernet subnet (VPN and cellular interfaces ignored)
|
- Only scans your actual Wi-Fi/Ethernet subnet (VPN and cellular interfaces ignored)
|
||||||
- Real-time feedback as hosts are tested
|
- Real-time feedback as hosts are tested
|
||||||
@@ -62,9 +62,23 @@ All done — the web app loads in full-screen kiosk mode.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Header Overlay Buttons
|
||||||
|
|
||||||
|
Three buttons are injected into the top-left of the web header by the kiosk app:
|
||||||
|
|
||||||
|
| Button | Action |
|
||||||
|
|--------|--------|
|
||||||
|
| **✕** | Exit kiosk mode (confirmation dialog) |
|
||||||
|
| **↻** | Hard-refresh — clears WebView cache and reloads the app |
|
||||||
|
| **⚙️** | Open EverShelf Settings |
|
||||||
|
|
||||||
|
The native Android settings button is permanently hidden once the overlay is injected — the **⚙️** web button replaces it entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Exiting Kiosk Mode
|
## Exiting Kiosk Mode
|
||||||
|
|
||||||
Tap the **✕** button in the header (top-left). A confirmation dialog appears.
|
Tap the **✕** button in the header overlay (top-left). A confirmation dialog appears.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -133,6 +147,6 @@ Requires Android Studio or JDK 17+ with the Android SDK.
|
|||||||
| `CAMERA` | Barcode scanning and AI photo identification |
|
| `CAMERA` | Barcode scanning and AI photo identification |
|
||||||
| `RECORD_AUDIO` | Voice input in AI chat |
|
| `RECORD_AUDIO` | Voice input in AI chat |
|
||||||
| `WAKE_LOCK` | Keep the screen on |
|
| `WAKE_LOCK` | Keep the screen on |
|
||||||
| `REQUEST_INSTALL_PACKAGES` | Install the Scale Gateway APK |
|
| `REQUEST_INSTALL_PACKAGES` | Over-the-air kiosk self-updates (installs new APK from GitHub releases) |
|
||||||
| `ACCESS_WIFI_STATE` | LAN auto-discovery |
|
| `ACCESS_WIFI_STATE` | LAN auto-discovery |
|
||||||
| `REORDER_TASKS` | Bring app to foreground after gateway launch |
|
| `REORDER_TASKS` | Bring the kiosk app to foreground when needed |
|
||||||
|
|||||||
@@ -43,15 +43,13 @@ docker compose up -d
|
|||||||
|
|
||||||
## AI Features
|
## AI Features
|
||||||
|
|
||||||
### AI features don't work / "AI non disponibile"
|
### "AI not available" error
|
||||||
|
|
||||||
1. Check that `GEMINI_API_KEY` is set in `.env`
|
1. Check that `GEMINI_API_KEY` is set in `.env`
|
||||||
2. Verify the key is valid at [aistudio.google.com](https://aistudio.google.com)
|
2. Verify the key is valid at [aistudio.google.com](https://aistudio.google.com)
|
||||||
3. Check that you haven't exceeded the free tier quota (15 req/min, 1500 req/day)
|
3. Check that you haven't exceeded the free tier quota (15 req/min, 1500 req/day)
|
||||||
4. Look for errors in the PHP error log
|
4. Look for errors in the PHP error log
|
||||||
|
|
||||||
### Recipe generation stops midway
|
|
||||||
|
|
||||||
This is usually a Gemini API timeout. The app streams results via SSE — if the server PHP timeout is too low, the stream is cut short. Increase `max_execution_time` in `php.ini`:
|
This is usually a Gemini API timeout. The app streams results via SSE — if the server PHP timeout is too low, the stream is cut short. Increase `max_execution_time` in `php.ini`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
@@ -62,7 +60,7 @@ max_execution_time = 120
|
|||||||
|
|
||||||
## Shopping List (Bring!)
|
## Shopping List (Bring!)
|
||||||
|
|
||||||
### "Bring! non configurato" message in the shopping tab
|
### "Bring! not configured" message in the shopping tab
|
||||||
|
|
||||||
Add your Bring! credentials to `.env`:
|
Add your Bring! credentials to `.env`:
|
||||||
|
|
||||||
@@ -90,7 +88,7 @@ BRING_PASSWORD=yourpassword
|
|||||||
### Scale shows weight but form doesn't auto-fill
|
### Scale shows weight but form doesn't auto-fill
|
||||||
|
|
||||||
- The auto-fill only triggers for products with unit `g` or `ml`
|
- The auto-fill only triggers for products with unit `g` or `ml`
|
||||||
- Make sure you tapped "⚖️ Leggi bilancia" first to activate the scale modal
|
- Make sure you tapped **"⚖️ Read Scale"** first to activate the scale modal
|
||||||
- The weight must stabilize (stay within 10g) for the countdown to start
|
- The weight must stabilize (stay within 10g) for the countdown to start
|
||||||
|
|
||||||
### Bluetooth scale not appearing in the gateway app
|
### Bluetooth scale not appearing in the gateway app
|
||||||
@@ -109,19 +107,19 @@ BRING_PASSWORD=yourpassword
|
|||||||
- Try entering the URL manually instead of using auto-discovery
|
- Try entering the URL manually instead of using auto-discovery
|
||||||
- Check that the server responds on the expected port (80/443/8080/8443)
|
- Check that the server responds on the expected port (80/443/8080/8443)
|
||||||
|
|
||||||
### Gateway install fails with an error dialog
|
### Kiosk app update fails
|
||||||
|
|
||||||
The dialog shows the exact failure code. Common causes:
|
The kiosk checks for a new release every 6 hours and downloads it from GitHub. If the install fails:
|
||||||
|
|
||||||
| Code | Cause | Fix |
|
| Symptom | Fix |
|
||||||
|------|-------|-----|
|
|---------|-----|
|
||||||
| `STATUS_FAILURE` (1) | Generic install failure — often OEM restriction | Enable "Install from unknown sources" for the kiosk app in Android Settings |
|
| "Install from unknown sources" dialog | Enable the setting for the EverShelf Kiosk app in Android Settings |
|
||||||
| `STATUS_FAILURE_CONFLICT` (3) | Signature mismatch with existing install | Uninstall the old gateway app, then retry |
|
| Persistent failure after download | Force-stop the app, clear its data, and relaunch the update flow |
|
||||||
| `STATUS_FAILURE_STORAGE` (6) | Not enough storage | Free up space on the device |
|
| Not enough space | Free up storage on the device |
|
||||||
|
|
||||||
### Exit button (✕) is not visible
|
### Exit button (✕) is not visible
|
||||||
|
|
||||||
The ✕ button is injected into the header by the kiosk app. If the web app's header is covered or the page failed to load, try the hard refresh (↻) button. If neither is visible, triple-tap the page title area to access the developer settings.
|
Three buttons are always visible in the kiosk header overlay: **✕** (exit), **↻** (refresh), **⚙️** (settings). If the page failed to load entirely, tap **↻** first. If nothing is visible, restart the device.
|
||||||
|
|
||||||
### App is stuck in kiosk mode after a crash
|
### App is stuck in kiosk mode after a crash
|
||||||
|
|
||||||
@@ -139,7 +137,7 @@ The version is cached by the browser. Do a hard refresh:
|
|||||||
|
|
||||||
### Transactions are missing from the log
|
### Transactions are missing from the log
|
||||||
|
|
||||||
The log shows the last 50 entries by default. Tap "Carica altri" to load more. Entries older than the database creation date won't appear.
|
The log shows the last 50 entries by default. Tap **"Load more"** to load more. Entries older than the database creation date won't appear.
|
||||||
|
|
||||||
### "Can only undo transactions within 24 hours"
|
### "Can only undo transactions within 24 hours"
|
||||||
|
|
||||||
|
|||||||
@@ -83,9 +83,9 @@ Recipes stream live via Server-Sent Events so results appear as they are generat
|
|||||||
### AI Chat Assistant
|
### AI Chat Assistant
|
||||||
|
|
||||||
Open **💬 Chat** to ask questions like:
|
Open **💬 Chat** to ask questions like:
|
||||||
- "Cosa posso fare con le uova e la pasta?"
|
- "What can I make with eggs and pasta?"
|
||||||
- "Quanti giorni dura il prosciutto cotto aperto in frigo?"
|
- "How long does cooked ham last once opened in the fridge?"
|
||||||
- "Suggeriscimi uno spuntino veloce"
|
- "Suggest a quick snack"
|
||||||
|
|
||||||
The assistant knows your current inventory.
|
The assistant knows your current inventory.
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ Configure `BRING_EMAIL` and `BRING_PASSWORD` in `.env` to enable.
|
|||||||
|
|
||||||
## 🍳 Cooking Mode
|
## 🍳 Cooking Mode
|
||||||
|
|
||||||
Start cooking mode from any recipe by tapping **▶ Avvia cottura**.
|
Start cooking mode from any recipe by tapping **▶ Start Cooking**.
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ Start cooking mode from any recipe by tapping **▶ Avvia cottura**.
|
|||||||
- Custom REST endpoint (e.g. Home Assistant)
|
- Custom REST endpoint (e.g. Home Assistant)
|
||||||
- **Built-in timers** — automatic timer suggestions based on recipe text; 10-second vocal countdown warning before expiry
|
- **Built-in timers** — automatic timer suggestions based on recipe text; 10-second vocal countdown warning before expiry
|
||||||
- **Ingredient tracking** — mark ingredients as used; leftover quantities prompt a "move to another location" flow
|
- **Ingredient tracking** — mark ingredients as used; leftover quantities prompt a "move to another location" flow
|
||||||
- **Recipe completion** — "Buon appetito!" spoken on the last step
|
- **Recipe completion** — "Buon appetito!" *(Enjoy your meal!)* spoken on the last step
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -155,8 +155,8 @@ Actions per item: Use, Throw away, Edit, Dismiss. Swipe or tap arrows to navigat
|
|||||||
Highlights suspicious quantities (e.g. "You have 0 eggs but used 12 this month"). Actions:
|
Highlights suspicious quantities (e.g. "You have 0 eggs but used 12 this month"). Actions:
|
||||||
- One-tap correction to the suggested quantity
|
- One-tap correction to the suggested quantity
|
||||||
- Inline edit with free-form quantity
|
- Inline edit with free-form quantity
|
||||||
- "🤖 Spiega" for AI explanation
|
- "🤖 Explain" for AI explanation
|
||||||
- Dismiss (with current quantity shown: "La quantità è giusta (2 pz)")
|
- Dismiss (with current quantity shown: "The quantity is correct (2 pcs)")
|
||||||
|
|
||||||
### Anti-Waste Report
|
### Anti-Waste Report
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,11 @@ All data stays on your server. No cloud, no subscriptions.
|
|||||||
## 🆕 What's New
|
## 🆕 What's New
|
||||||
|
|
||||||
### v1.7.13 (2026-05-16)
|
### v1.7.13 (2026-05-16)
|
||||||
- **Critical fix:** Fresh-install crash resolved — `transactions` schema was missing the `undone` column, causing a database failure on every new installation
|
- **Fix:** Kiosk Settings button (⚙️) added to the web overlay — tapping the camera button no longer accidentally opens kiosk settings
|
||||||
- **Fix:** Race condition in DB migrations no longer causes `duplicate column name` errors on concurrent first requests
|
- **Fix:** Opened-item expiry badge is now consistent with the top banner: low-risk items (jams, condiments) show amber ⚠️ "Check soon" instead of misleading red ⛔ "Expired"
|
||||||
|
- **Cooking Mode:** 3D wheel UI with perspective card flip, ghost steps (prev/next), float animation, and full `prefers-reduced-motion` support
|
||||||
|
- **CI:** `data/category_ai_cache.json` added to `.gitignore`
|
||||||
|
- **Critical fix (DB):** Fresh-install crash resolved — `transactions` schema was missing the `undone` column
|
||||||
|
|
||||||
### v1.7.12 (2026-05-13)
|
### v1.7.12 (2026-05-13)
|
||||||
- "Use first" banner now shows opening date and location instead of a confusing calculated expiry
|
- "Use first" banner now shows opening date and location instead of a confusing calculated expiry
|
||||||
@@ -81,7 +84,7 @@ EverShelf/
|
|||||||
├── translations/ # i18n JSON files (it, en, de)
|
├── translations/ # i18n JSON files (it, en, de)
|
||||||
├── docs/openapi.yaml # OpenAPI 3.0 spec
|
├── docs/openapi.yaml # OpenAPI 3.0 spec
|
||||||
├── evershelf-kiosk/ # Android kiosk app (Kotlin)
|
├── evershelf-kiosk/ # Android kiosk app (Kotlin)
|
||||||
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin)
|
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin) — DEPRECATED, built into kiosk since v1.6.0
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ In EverShelf **Settings → Scale**:
|
|||||||
|
|
||||||
### 4. Connect your scale
|
### 4. Connect your scale
|
||||||
|
|
||||||
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is powered on. Tap it in the list to pair and connect.
|
Tap **"Find Bluetooth Scales"**. Make sure your scale is powered on. Tap it in the list to pair and connect.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale
|
|||||||
When scale integration is enabled:
|
When scale integration is enabled:
|
||||||
|
|
||||||
1. Open the **Add** or **Use** form for any product with unit `g` or `ml`
|
1. Open the **Add** or **Use** form for any product with unit `g` or `ml`
|
||||||
2. A **"⚖️ Leggi bilancia"** button appears
|
2. A **"⚖️ Read Scale"** button appears
|
||||||
3. Tap it — a live weight display appears with a stability indicator
|
3. Tap it — a live weight display appears with a stability indicator
|
||||||
4. Step on or place the product on the scale
|
4. Step on or place the product on the scale
|
||||||
5. When the reading stabilizes, a **5-second countdown** starts
|
5. When the reading stabilizes, a **5-second countdown** starts
|
||||||
@@ -127,7 +127,7 @@ Every 6 hours the gateway app checks GitHub releases. If a newer version is avai
|
|||||||
### Weight not appearing in EverShelf
|
### Weight not appearing in EverShelf
|
||||||
- Confirm the Gateway URL in EverShelf Settings matches the URL shown in the gateway app
|
- Confirm the Gateway URL in EverShelf Settings matches the URL shown in the gateway app
|
||||||
- Check that the Android device and the EverShelf server are on the same network
|
- Check that the Android device and the EverShelf server are on the same network
|
||||||
- Tap "Disconnetti / Riconnetti" in the gateway app to refresh the WebSocket connection
|
- Tap "Disconnect / Reconnect" in the gateway app to refresh the WebSocket connection
|
||||||
|
|
||||||
### "Mixed content" error in browser
|
### "Mixed content" error in browser
|
||||||
- Make sure you are accessing EverShelf over HTTPS (not plain HTTP)
|
- Make sure you are accessing EverShelf over HTTPS (not plain HTTP)
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 13
|
versionCode = 14
|
||||||
versionName = "1.7.2"
|
versionName = "1.7.13"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
# Build trigger: TTS bridge fix (95389eb)
|
# Build trigger: versionName 1.7.13 fix (8d87494)
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
.gradle/
|
|
||||||
build/
|
|
||||||
local.properties
|
|
||||||
*.apk
|
|
||||||
*.aab
|
|
||||||
*.class
|
|
||||||
*.dex
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
# ~~EverShelf Scale Gateway~~ — DEPRECATED
|
|
||||||
|
|
||||||
> ⚠️ **This app is deprecated and no longer maintained.**
|
|
||||||
>
|
|
||||||
> As of **EverShelf Kiosk v1.6.0**, BLE scale support is fully integrated into the kiosk app itself. You no longer need to install or configure this separate gateway app.
|
|
||||||
>
|
|
||||||
> **If you are using the EverShelf Kiosk app** → the scale gateway runs automatically as a background service. Configure your Bluetooth scale in **step 4 of the setup wizard**.
|
|
||||||
>
|
|
||||||
> **If you are NOT using the kiosk app** (standalone Android tablet) → you may still use this APK, but no new releases will be published.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# EverShelf Scale Gateway (legacy)
|
|
||||||
|
|
||||||
> Android gateway app that bridges Bluetooth LE smart scales with EverShelf via WebSocket.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
```
|
|
||||||
Smart Scale ──(BLE)──► Android Gateway App ──(WebSocket/LAN)──► EverShelf Server ──(SSE)──► Browser
|
|
||||||
```
|
|
||||||
|
|
||||||
The app runs a local WebSocket server (port **8765**) on your Android device. The EverShelf server connects to it via a server-side relay (`api/scale_relay.php` SSE + `api/scale_ping.php` WebSocket client), avoiding mixed-content (HTTPS→WS) issues. Weight readings are streamed to the browser in real time.
|
|
||||||
|
|
||||||
> **Kiosk integration (v1.6.0+):** The gateway is now **built into the EverShelf Kiosk app** as a foreground service. This separate app is not needed when using the kiosk.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Supported scale protocols
|
|
||||||
|
|
||||||
| Protocol | Service UUID | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| **Bluetooth SIG Weight Scale** | `0x181D` / char `0x2A9D` | Most compatible; works with most smart scales |
|
|
||||||
| **Bluetooth SIG Body Composition** | `0x181B` / char `0x2A9C` | Reports weight + body fat %, BMI |
|
|
||||||
| **Generic fallback** | Any notifiable characteristic | Auto-heuristic parsing for 100+ models |
|
|
||||||
|
|
||||||
### Verified compatible scales (community list)
|
|
||||||
- Xiaomi Mi Body Composition Scale 2
|
|
||||||
- Renpho Smart Body Fat Scale
|
|
||||||
- INEVIFIT Smart Body Fat Scale
|
|
||||||
- Any OpenScale-compatible scale (see [openScale supported devices](https://github.com/oliexdev/openScale/wiki/Supported-scales))
|
|
||||||
|
|
||||||
> **Your scale (B09MRXVBV6):** If it implements the standard BLE Weight Scale or Body Composition profile (very likely for modern Amazon smart scales), the gateway will connect automatically. If not, check the [openScale wiki](https://github.com/oliexdev/openScale/wiki/Supported-scales) and open an issue.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Download
|
|
||||||
|
|
||||||
Download the latest APK directly: **[evershelf-scale-gateway.apk](https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Android **7.0** (API 24) or later
|
|
||||||
- Bluetooth LE (BLE) support
|
|
||||||
- Both the Android device and the device running EverShelf must be on the **same Wi-Fi network**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setup (step by step)
|
|
||||||
|
|
||||||
### 1. Install the APK
|
|
||||||
Download and install the APK from the Releases page. You may need to allow "Install from unknown sources" in Android settings.
|
|
||||||
|
|
||||||
### 2. Launch the app
|
|
||||||
The app starts the WebSocket gateway server immediately. You will see the **gateway URL** (e.g. `ws://192.168.1.100:8765`) at the top.
|
|
||||||
|
|
||||||
### 3. Connect your scale
|
|
||||||
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is turned on. Tap it in the list to connect.
|
|
||||||
|
|
||||||
### 4. Configure EverShelf
|
|
||||||
In EverShelf → ⚙️ Settings → **⚖️ Bilancia Smart**:
|
|
||||||
1. Enable the toggle
|
|
||||||
2. Paste the gateway URL shown in the Android app
|
|
||||||
3. Tap **"Testa connessione"** — you should see ✅
|
|
||||||
|
|
||||||
### 5. Use it
|
|
||||||
When adding or consuming a product with unit **g** or **ml**, a **"⚖️ Leggi dalla bilancia"** button appears. Tap it, place the product on the scale, and the weight is filled in automatically.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## WebSocket protocol reference
|
|
||||||
|
|
||||||
All messages are JSON. The server sends these to connected clients:
|
|
||||||
|
|
||||||
```json
|
|
||||||
// Scale status update
|
|
||||||
{"type":"status","state":"connected","device":"Mi Scale 2","battery":85}
|
|
||||||
{"type":"status","state":"disconnected"}
|
|
||||||
|
|
||||||
// Weight reading (broadcast continuously while scale is active)
|
|
||||||
{"type":"weight","value":72.50,"unit":"kg","stable":true,"timestamp":1712345678000}
|
|
||||||
|
|
||||||
// Response to ping
|
|
||||||
{"type":"pong"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Clients can send:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"type":"get_status"} // Request current status
|
|
||||||
{"type":"get_weight"} // Request next stable weight reading
|
|
||||||
{"type":"ping"} // Keep-alive
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Build from source
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- Android Studio Hedgehog (2023.1) or later
|
|
||||||
- Java 8+
|
|
||||||
|
|
||||||
### Steps
|
|
||||||
```bash
|
|
||||||
# 1. Clone the repo
|
|
||||||
git clone https://github.com/dadaloop82/EverShelf.git
|
|
||||||
cd EverShelf/evershelf-scale-gateway
|
|
||||||
|
|
||||||
# 2. Download the Gradle wrapper (if not included)
|
|
||||||
gradle wrapper --gradle-version 8.4
|
|
||||||
|
|
||||||
# 3. Build debug APK
|
|
||||||
./gradlew assembleDebug
|
|
||||||
|
|
||||||
# APK is at: app/build/outputs/apk/debug/app-debug.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
```
|
|
||||||
evershelf-scale-gateway/
|
|
||||||
├── app/src/main/
|
|
||||||
│ ├── kotlin/it/dadaloop/evershelf/scalegate/
|
|
||||||
│ │ ├── MainActivity.kt — UI, orchestration
|
|
||||||
│ │ ├── BleScaleManager.kt — BLE scanning & GATT connection
|
|
||||||
│ │ ├── ScaleProtocol.kt — Parsing for all supported protocols
|
|
||||||
│ │ └── GatewayWebSocketServer.kt — WebSocket server (Java-WebSocket)
|
|
||||||
│ ├── res/layout/
|
|
||||||
│ │ ├── activity_main.xml
|
|
||||||
│ │ └── item_device.xml
|
|
||||||
│ └── AndroidManifest.xml
|
|
||||||
├── build.gradle.kts
|
|
||||||
└── settings.gradle.kts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT — see [LICENSE](../LICENSE)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "it.dadaloop.evershelf.scalegate"
|
|
||||||
compileSdk = 34
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "it.dadaloop.evershelf.scalegate"
|
|
||||||
minSdk = 24
|
|
||||||
targetSdk = 34
|
|
||||||
versionCode = 8
|
|
||||||
versionName = "2.1.1"
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
viewBinding = true
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
|
||||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
|
||||||
implementation("com.google.android.material:material:1.11.0")
|
|
||||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
|
||||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
|
||||||
// WebSocket server
|
|
||||||
implementation("org.java-websocket:Java-WebSocket:1.5.5")
|
|
||||||
// Coroutines
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<!-- BLE permissions for Android < 12 -->
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
|
||||||
android:maxSdkVersion="30" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
|
||||||
android:maxSdkVersion="30" />
|
|
||||||
|
|
||||||
<!-- BLE permissions for Android 12+ -->
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
||||||
|
|
||||||
<!-- Location (required for BLE scanning on Android 6–11) -->
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
|
||||||
|
|
||||||
<!-- Network (for WebSocket server) -->
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
|
||||||
|
|
||||||
<!-- Keep screen on while gateway is active -->
|
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
||||||
|
|
||||||
<!-- Self-update: install APK downloaded at runtime -->
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar">
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:screenOrientation="portrait"
|
|
||||||
android:windowSoftInputMode="adjustResize">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<!-- FileProvider for serving the downloaded APK to the installer -->
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.provider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
package it.dadaloop.evershelf.scalegate
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.bluetooth.*
|
|
||||||
import android.bluetooth.le.*
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
|
|
||||||
private const val TAG = "BleScaleManager"
|
|
||||||
private const val SCAN_PERIOD_MS = 15_000L
|
|
||||||
private const val PREFS_NAME = "evershelf_gateway"
|
|
||||||
private const val PREF_LAST_DEVICE = "last_device_address"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a discovered BLE device during scan.
|
|
||||||
*/
|
|
||||||
data class BleDeviceInfo(
|
|
||||||
val device: BluetoothDevice,
|
|
||||||
val name: String,
|
|
||||||
val rssi: Int,
|
|
||||||
val proximity: String,
|
|
||||||
val scaleScore: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback interface for BLE events dispatched back to the UI.
|
|
||||||
*/
|
|
||||||
interface BleScaleListener {
|
|
||||||
fun onDeviceFound(info: BleDeviceInfo)
|
|
||||||
fun onConnecting(device: BluetoothDevice)
|
|
||||||
fun onConnected(deviceName: String)
|
|
||||||
fun onDisconnected()
|
|
||||||
fun onWeightReceived(reading: WeightReading)
|
|
||||||
fun onBatteryReceived(level: Int)
|
|
||||||
fun onError(message: String)
|
|
||||||
fun onScanStopped()
|
|
||||||
fun onDebugEvent(message: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages BLE scanning and connection to a smart scale.
|
|
||||||
* All listener callbacks are dispatched on the main thread.
|
|
||||||
*/
|
|
||||||
class BleScaleManager(
|
|
||||||
private val context: Context,
|
|
||||||
private val listener: BleScaleListener,
|
|
||||||
) {
|
|
||||||
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
|
||||||
private val bluetoothAdapter: BluetoothAdapter? get() = bluetoothManager.adapter
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
|
||||||
|
|
||||||
private var leScanner: BluetoothLeScanner? = null
|
|
||||||
private var gatt: BluetoothGatt? = null
|
|
||||||
private var isScanning = false
|
|
||||||
private var connectedDeviceName: String = ""
|
|
||||||
private var autoConnectAddress: String? = null
|
|
||||||
|
|
||||||
// The characteristics we will subscribe to (multiple may exist).
|
|
||||||
private val pendingSubscriptions = ArrayDeque<BluetoothGattCharacteristic>()
|
|
||||||
|
|
||||||
// ─── Public state ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty()
|
|
||||||
|
|
||||||
// ─── Saved device (auto-reconnect) ─────────────────────────────────────────
|
|
||||||
|
|
||||||
fun getSavedDeviceAddress(): String? {
|
|
||||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
.getString(PREF_LAST_DEVICE, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveDeviceAddress(address: String) {
|
|
||||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
.edit().putString(PREF_LAST_DEVICE, address).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enableAutoConnect() {
|
|
||||||
autoConnectAddress = getSavedDeviceAddress()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Permissions helper ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun hasRequiredPermissions(): Boolean {
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
|
|
||||||
} else {
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Scanning ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun startScan() {
|
|
||||||
val adapter = bluetoothAdapter ?: run {
|
|
||||||
listener.onError("Bluetooth not available on this device.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!adapter.isEnabled) {
|
|
||||||
listener.onError("Bluetooth is off. Enable it and try again.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isScanning) stopScan()
|
|
||||||
|
|
||||||
leScanner = adapter.bluetoothLeScanner
|
|
||||||
val settings = ScanSettings.Builder()
|
|
||||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
// No service UUID filters — many consumer scales use proprietary UUIDs
|
|
||||||
// and would be invisible with strict filtering. We show all named BLE devices.
|
|
||||||
isScanning = true
|
|
||||||
try {
|
|
||||||
leScanner?.startScan(null, settings, scanCallback)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
leScanner?.startScan(scanCallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-stop after SCAN_PERIOD_MS
|
|
||||||
mainHandler.postDelayed({
|
|
||||||
stopScan()
|
|
||||||
listener.onScanStopped()
|
|
||||||
}, SCAN_PERIOD_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopScan() {
|
|
||||||
if (!isScanning) return
|
|
||||||
isScanning = false
|
|
||||||
try {
|
|
||||||
leScanner?.stopScan(scanCallback)
|
|
||||||
} catch (e: Exception) { /* ignore */ }
|
|
||||||
leScanner = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private val scanCallback = object : ScanCallback() {
|
|
||||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
|
||||||
val device = result.device
|
|
||||||
val name = result.scanRecord?.deviceName?.takeIf { it.isNotBlank() }
|
|
||||||
?: getDeviceName(device)
|
|
||||||
val proximity = rssiToProximity(result.rssi)
|
|
||||||
val score = scoreLikelyScale(name, result.scanRecord)
|
|
||||||
val info = BleDeviceInfo(device, name, result.rssi, proximity, score)
|
|
||||||
mainHandler.post { listener.onDeviceFound(info) }
|
|
||||||
|
|
||||||
// Auto-connect to saved device
|
|
||||||
if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) {
|
|
||||||
autoConnectAddress = null // prevent re-trigger
|
|
||||||
mainHandler.post {
|
|
||||||
listener.onDebugEvent("\uD83D\uDD04 Auto-connecting to $name (${device.address})")
|
|
||||||
connect(device)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onScanFailed(errorCode: Int) {
|
|
||||||
isScanning = false
|
|
||||||
mainHandler.post { listener.onError("BLE scan failed (code: $errorCode)") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getDeviceName(device: BluetoothDevice): String {
|
|
||||||
return try {
|
|
||||||
device.name?.takeIf { it.isNotBlank() } ?: "Unnamed"
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
"Unnamed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun rssiToProximity(rssi: Int) = when {
|
|
||||||
rssi >= -60 -> "📶 Near"
|
|
||||||
rssi >= -80 -> "📶 Medium"
|
|
||||||
else -> "📶 Far"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scoreLikelyScale(name: String, scanRecord: android.bluetooth.le.ScanRecord?): Int {
|
|
||||||
var score = 0
|
|
||||||
val lower = name.lowercase()
|
|
||||||
// Kitchen / food scale brand and model keywords
|
|
||||||
val foodKeywords = listOf(
|
|
||||||
"scale", "bilancia", "kitchen", "food", "cucina",
|
|
||||||
"coffee", "caffe", "balance", "weight", "waage",
|
|
||||||
"arboleaf", "ck10", "ck20", "ek-",
|
|
||||||
"acaia", "felicita", "decent", "skale",
|
|
||||||
"timemore", "brewista", "hario",
|
|
||||||
"greater goods", "ozeri", "etekcity", "nutri",
|
|
||||||
"nicewell", "koios", "renpho", "eatsmart",
|
|
||||||
)
|
|
||||||
if (foodKeywords.any { lower.contains(it) }) score += 10
|
|
||||||
|
|
||||||
// Negative: body/fitness scale keywords (demote but don't hide)
|
|
||||||
val bodyKeywords = listOf(
|
|
||||||
"body", "fat", "bmi", "composition", "fitness",
|
|
||||||
"mi body", "lepulse", "qardio", "garmin", "withings",
|
|
||||||
)
|
|
||||||
if (bodyKeywords.any { lower.contains(it) }) score -= 5
|
|
||||||
|
|
||||||
// Service UUID scoring
|
|
||||||
scanRecord?.serviceUuids?.let { uuids ->
|
|
||||||
val us = uuids.map { it.uuid.toString().lowercase() }
|
|
||||||
// SIG Weight Scale service
|
|
||||||
if (us.any { it.startsWith("0000181d") }) score += 15
|
|
||||||
// Common vendor services on kitchen scales
|
|
||||||
if (us.any { it.startsWith("0000ffe0") || it.startsWith("0000fff0") }) score += 10
|
|
||||||
// Acaia coffee scale
|
|
||||||
if (us.any { it.startsWith("49535343") }) score += 20
|
|
||||||
// Body Composition service = body scale, demote
|
|
||||||
if (us.any { it.startsWith("0000181b") }) score -= 10
|
|
||||||
}
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Connection ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun connect(device: BluetoothDevice) {
|
|
||||||
stopScan()
|
|
||||||
disconnect()
|
|
||||||
connectedDeviceName = ""
|
|
||||||
ScaleProtocol.resetState()
|
|
||||||
mainHandler.post { listener.onConnecting(device) }
|
|
||||||
try {
|
|
||||||
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
|
||||||
} else {
|
|
||||||
device.connectGatt(context, false, gattCallback)
|
|
||||||
}
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
mainHandler.post { listener.onError("Missing permission: ${e.message}") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disconnect() {
|
|
||||||
pendingSubscriptions.clear()
|
|
||||||
try {
|
|
||||||
gatt?.disconnect()
|
|
||||||
gatt?.close()
|
|
||||||
} catch (e: Exception) { /* ignore */ }
|
|
||||||
gatt = null
|
|
||||||
connectedDeviceName = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── GATT callbacks ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private val gattCallback = object : BluetoothGattCallback() {
|
|
||||||
|
|
||||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
|
||||||
when (newState) {
|
|
||||||
BluetoothProfile.STATE_CONNECTED -> {
|
|
||||||
Log.d(TAG, "Connected — discovering services…")
|
|
||||||
mainHandler.postDelayed({ gatt.discoverServices() }, 500)
|
|
||||||
}
|
|
||||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
|
||||||
Log.d(TAG, "Disconnected (status=$status)")
|
|
||||||
this@BleScaleManager.gatt?.close()
|
|
||||||
this@BleScaleManager.gatt = null
|
|
||||||
connectedDeviceName = ""
|
|
||||||
mainHandler.post { listener.onDisconnected() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
|
||||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
|
||||||
mainHandler.post { listener.onError("Servizi GATT non trovati (status=$status)") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val targetChars = mutableListOf<BluetoothGattCharacteristic>()
|
|
||||||
|
|
||||||
// Priority 1: BLE SIG Weight Scale Service
|
|
||||||
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)
|
|
||||||
?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)
|
|
||||||
?.let { targetChars.add(it) }
|
|
||||||
|
|
||||||
// Priority 2: Common vendor service FFE0 (arboleaf, generic kitchen scales)
|
|
||||||
gatt.getService(BleUuids.FFE0)?.let { svc ->
|
|
||||||
svc.getCharacteristic(BleUuids.FFE1)?.let { targetChars.add(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 3: Common vendor service FFF0
|
|
||||||
gatt.getService(BleUuids.FFF0)?.let { svc ->
|
|
||||||
svc.getCharacteristic(BleUuids.FFF4)?.let { targetChars.add(it) }
|
|
||||||
?: svc.getCharacteristic(BleUuids.FFF1)?.let { targetChars.add(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 4: Acaia coffee scale
|
|
||||||
gatt.getService(BleUuids.ACAIA_SERVICE)?.let { svc ->
|
|
||||||
svc.getCharacteristic(BleUuids.ACAIA_CHAR)?.let { targetChars.add(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: any notifiable characteristic from remaining services
|
|
||||||
if (targetChars.isEmpty()) {
|
|
||||||
for (service in gatt.services) {
|
|
||||||
if (service.uuid.toString().startsWith("00001800") ||
|
|
||||||
service.uuid.toString().startsWith("00001801")) continue
|
|
||||||
for (char in service.characteristics) {
|
|
||||||
val props = char.properties
|
|
||||||
if ((props and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 ||
|
|
||||||
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) {
|
|
||||||
if (!targetChars.contains(char)) targetChars.add(char)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetChars.isEmpty()) {
|
|
||||||
mainHandler.post { listener.onError("No weight characteristic found. Make sure it's a BLE kitchen scale.") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Battery (optional)
|
|
||||||
gatt.getService(BleUuids.BATTERY_SERVICE)
|
|
||||||
?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)
|
|
||||||
?.let { targetChars.add(it) }
|
|
||||||
|
|
||||||
// Debug: log all discovered services and characteristics
|
|
||||||
val dbg = buildString {
|
|
||||||
append("GATT services (${gatt.services.size}):\n")
|
|
||||||
for (svc in gatt.services) {
|
|
||||||
append(" SVC: ${svc.uuid}\n")
|
|
||||||
for (ch in svc.characteristics) {
|
|
||||||
val p = ch.properties
|
|
||||||
val flags = buildString {
|
|
||||||
if (p and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) append("N")
|
|
||||||
if (p and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) append("I")
|
|
||||||
if (p and BluetoothGattCharacteristic.PROPERTY_READ != 0) append("R")
|
|
||||||
if (p and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) append("W")
|
|
||||||
}
|
|
||||||
append(" CHAR: ${ch.uuid} [$flags]\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
append("Subscribed to ${targetChars.size} characteristics")
|
|
||||||
}
|
|
||||||
mainHandler.post { listener.onDebugEvent(dbg) }
|
|
||||||
|
|
||||||
// Save device for auto-reconnect
|
|
||||||
try { gatt.device?.address?.let { saveDeviceAddress(it) } } catch (_: SecurityException) {}
|
|
||||||
|
|
||||||
pendingSubscriptions.clear()
|
|
||||||
pendingSubscriptions.addAll(targetChars)
|
|
||||||
|
|
||||||
val deviceName = try { gatt.device?.name ?: "Scale" } catch (e: SecurityException) { "Scale" }
|
|
||||||
connectedDeviceName = deviceName
|
|
||||||
mainHandler.post { listener.onConnected(deviceName) }
|
|
||||||
|
|
||||||
// Subscribe one at a time (Android BLE requires sequential descriptor writes)
|
|
||||||
subscribeNext(gatt)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
|
||||||
// Subscribe to the next characteristic
|
|
||||||
subscribeNext(gatt)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
override fun onCharacteristicChanged(
|
|
||||||
gatt: BluetoothGatt,
|
|
||||||
characteristic: BluetoothGattCharacteristic,
|
|
||||||
) {
|
|
||||||
val data = characteristic.value ?: return
|
|
||||||
processCharacteristicData(characteristic, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Android 13+ override
|
|
||||||
override fun onCharacteristicChanged(
|
|
||||||
gatt: BluetoothGatt,
|
|
||||||
characteristic: BluetoothGattCharacteristic,
|
|
||||||
value: ByteArray,
|
|
||||||
) {
|
|
||||||
processCharacteristicData(characteristic, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
override fun onCharacteristicRead(
|
|
||||||
gatt: BluetoothGatt,
|
|
||||||
characteristic: BluetoothGattCharacteristic,
|
|
||||||
status: Int,
|
|
||||||
) {
|
|
||||||
if (status == BluetoothGatt.GATT_SUCCESS && characteristic.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
|
|
||||||
val level = characteristic.value?.firstOrNull()?.toInt()?.and(0xFF)
|
|
||||||
if (level != null) mainHandler.post { listener.onBatteryReceived(level) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun subscribeNext(gatt: BluetoothGatt) {
|
|
||||||
val char = pendingSubscriptions.removeFirstOrNull() ?: return
|
|
||||||
|
|
||||||
// Battery characteristic — read once instead of notify
|
|
||||||
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
|
|
||||||
try { gatt.readCharacteristic(char) } catch (e: SecurityException) { /* ignore */ }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val props = char.properties
|
|
||||||
val notifyType = when {
|
|
||||||
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 ->
|
|
||||||
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
|
|
||||||
else -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
gatt.setCharacteristicNotification(char, true)
|
|
||||||
val descriptor = char.getDescriptor(CCCD_UUID) ?: run {
|
|
||||||
// No CCCD — skip and try next
|
|
||||||
subscribeNext(gatt)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
gatt.writeDescriptor(descriptor, notifyType)
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
descriptor.value = notifyType
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
gatt.writeDescriptor(descriptor)
|
|
||||||
}
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.e(TAG, "SecurityException enabling notification", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun processCharacteristicData(char: BluetoothGattCharacteristic, data: ByteArray) {
|
|
||||||
// Battery level
|
|
||||||
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR && data.isNotEmpty()) {
|
|
||||||
val level = data[0].toInt() and 0xFF
|
|
||||||
mainHandler.post { listener.onBatteryReceived(level) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: log raw bytes received
|
|
||||||
val hex = data.joinToString(" ") { "%02X".format(it) }
|
|
||||||
mainHandler.post { listener.onDebugEvent("📡 ${char.uuid}\n HEX [${data.size}B]: $hex") }
|
|
||||||
|
|
||||||
// Parse weight data
|
|
||||||
val reading = ScaleProtocol.parse(char, data) { msg ->
|
|
||||||
mainHandler.post { listener.onDebugEvent(msg) }
|
|
||||||
}
|
|
||||||
if (reading != null && reading.value > 0f) {
|
|
||||||
mainHandler.post { listener.onWeightReceived(reading) }
|
|
||||||
} else {
|
|
||||||
val rawDump = data.mapIndexed { i, b ->
|
|
||||||
val v = b.toInt() and 0xFF
|
|
||||||
val h = "%02X".format(v)
|
|
||||||
"[$i]=$v(0x$h)"
|
|
||||||
}.joinToString(" ")
|
|
||||||
mainHandler.post { listener.onDebugEvent("\u26a0\ufe0f Weight not decoded\n RAW: $rawDump") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
package it.dadaloop.evershelf.scalegate
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.io.OutputStreamWriter
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Centralized error reporter for EverShelf Scale Gateway.
|
|
||||||
*
|
|
||||||
* Unlike the Kiosk (which relays errors through the EverShelf PHP backend),
|
|
||||||
* the Scale Gateway has no knowledge of the EverShelf server URL, so it
|
|
||||||
* calls the GitHub Issues REST API directly.
|
|
||||||
*
|
|
||||||
* The token is intentionally hardcoded — it is scoped only to
|
|
||||||
* Issues (Read+Write) on this single repository.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ErrorReporter.init(applicationContext)
|
|
||||||
* ErrorReporter.report(exception, "methodName", mapOf("extra" to "info"))
|
|
||||||
* ErrorReporter.reportMessage("ble-disconnect", "Scale disconnected after 3 retries")
|
|
||||||
*/
|
|
||||||
object ErrorReporter {
|
|
||||||
|
|
||||||
private const val TAG = "ScaleGWErrorReporter"
|
|
||||||
|
|
||||||
// ── XOR-obfuscated GitHub token (scoped: Issues R+W on dadaloop82/EverShelf) ──
|
|
||||||
// Stored encoded so the literal token string never appears in source or git history.
|
|
||||||
private const val GH_TOKEN_ENC = "23580718460c2c444031290243627e7971622b29035e2a647726407d194f61440b6e05246a0c067c79730e77114b774501730043433d1866682225511b5443417170444443142941673c4046086c05737363293e7821006e470a466a1d"
|
|
||||||
private const val GH_TOKEN_KEY = "D1sp3ns4!Ev3r#26"
|
|
||||||
private const val GH_REPO = "dadaloop82/EverShelf"
|
|
||||||
|
|
||||||
private var _ghTokenCache: String? = null
|
|
||||||
private fun ghToken(): String {
|
|
||||||
_ghTokenCache?.let { return it }
|
|
||||||
val enc = GH_TOKEN_ENC.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
|
||||||
val key = GH_TOKEN_KEY
|
|
||||||
val out = String(ByteArray(enc.size) { i -> (enc[i].toInt() xor key[i % key.length].code).toByte() })
|
|
||||||
_ghTokenCache = out
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// SharedPreferences key for pending (unsent) crash reports
|
|
||||||
private const val PREFS_NAME = "evershelf_scalegw_errors"
|
|
||||||
private const val KEY_PENDING = "pending_crash_json"
|
|
||||||
|
|
||||||
private val executor = Executors.newSingleThreadExecutor()
|
|
||||||
private val sentFingerprints = mutableSetOf<String>()
|
|
||||||
|
|
||||||
private var appVersion: String = "unknown"
|
|
||||||
private var deviceInfo: String = ""
|
|
||||||
private lateinit var appContext: Context
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call once in MainActivity.onCreate() or Application.onCreate().
|
|
||||||
*/
|
|
||||||
fun init(context: Context) {
|
|
||||||
appContext = context.applicationContext
|
|
||||||
deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})"
|
|
||||||
try {
|
|
||||||
val pi = context.packageManager.getPackageInfo(context.packageName, 0)
|
|
||||||
appVersion = pi.versionName ?: "unknown"
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
|
|
||||||
// Send any crash report that was saved from the previous session
|
|
||||||
sendPendingCrash()
|
|
||||||
|
|
||||||
// Install global UncaughtExceptionHandler
|
|
||||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
|
||||||
try {
|
|
||||||
val crash = buildPayload(
|
|
||||||
type = "uncaught-exception",
|
|
||||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
|
||||||
stack = throwable.stackTraceToString(),
|
|
||||||
context = mapOf("thread" to thread.name)
|
|
||||||
)
|
|
||||||
// Save to prefs first (in case network POST fails before process dies)
|
|
||||||
savePendingCrash(crash)
|
|
||||||
// Try immediate send (synchronous — we're already off main thread in the handler)
|
|
||||||
postToGitHub(crash)
|
|
||||||
clearPendingCrash()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
previous?.uncaughtException(thread, throwable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Report a caught [Throwable] asynchronously. */
|
|
||||||
fun report(throwable: Throwable, location: String = "", extra: Map<String, Any?> = emptyMap()) {
|
|
||||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
|
||||||
if (location.isNotEmpty()) ctx["location"] = location
|
|
||||||
ctx.putAll(extra)
|
|
||||||
enqueue(
|
|
||||||
type = "scale-exception",
|
|
||||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
|
||||||
stack = throwable.stackTraceToString(),
|
|
||||||
context = ctx
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Report a non-exception event (e.g. BLE disconnect, WebSocket error). */
|
|
||||||
fun reportMessage(type: String, message: String, extra: Map<String, Any?> = emptyMap()) {
|
|
||||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
|
||||||
ctx.putAll(extra)
|
|
||||||
enqueue(type = type, message = message, stack = "", context = ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun fingerprint(type: String, message: String) =
|
|
||||||
"${type}:${message.take(120)}".hashCode().toString(16)
|
|
||||||
|
|
||||||
private fun enqueue(type: String, message: String, stack: String, context: Map<String, Any?>) {
|
|
||||||
val fp = fingerprint(type, message)
|
|
||||||
synchronized(sentFingerprints) {
|
|
||||||
if (!sentFingerprints.add(fp)) return
|
|
||||||
}
|
|
||||||
val payload = buildPayload(type, message, stack, context)
|
|
||||||
executor.execute { postToGitHub(payload) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildPayload(type: String, message: String, stack: String, context: Map<String, Any?>): JSONObject {
|
|
||||||
val ctxJson = JSONObject()
|
|
||||||
context.forEach { (k, v) -> ctxJson.put(k, v) }
|
|
||||||
return JSONObject().apply {
|
|
||||||
put("source", "scale")
|
|
||||||
put("type", type)
|
|
||||||
put("message", message)
|
|
||||||
put("stack", stack)
|
|
||||||
put("context", ctxJson)
|
|
||||||
put("version", appVersion)
|
|
||||||
put("user_agent", "EverShelf-ScaleGateway/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})")
|
|
||||||
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persist crash payload to SharedPreferences so it survives a process kill. */
|
|
||||||
private fun savePendingCrash(payload: JSONObject) {
|
|
||||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
.edit().putString(KEY_PENDING, payload.toString()).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearPendingCrash() {
|
|
||||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
.edit().remove(KEY_PENDING).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** On startup, check if there's an unsent crash report from the previous session. */
|
|
||||||
private fun sendPendingCrash() {
|
|
||||||
val json = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
.getString(KEY_PENDING, null) ?: return
|
|
||||||
clearPendingCrash() // remove before sending to prevent re-sending on next crash
|
|
||||||
executor.execute {
|
|
||||||
try {
|
|
||||||
val payload = JSONObject(json)
|
|
||||||
// Tag it as a "survived-crash" so we know it was saved and retried
|
|
||||||
payload.put("type", "uncaught-exception-survived")
|
|
||||||
payload.put("note", "Sent on next launch after crash")
|
|
||||||
postToGitHub(payload)
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a GitHub Issue (or add a comment to an existing one with the same fingerprint).
|
|
||||||
* Uses the GitHub Issues Search API to deduplicate.
|
|
||||||
*/
|
|
||||||
private fun postToGitHub(payload: JSONObject) {
|
|
||||||
val source = payload.optString("source", "scale")
|
|
||||||
val type = payload.optString("type", "error")
|
|
||||||
val message = payload.optString("message", "")
|
|
||||||
val stack = payload.optString("stack", "")
|
|
||||||
val version = payload.optString("version", "")
|
|
||||||
val ua = payload.optString("user_agent", "")
|
|
||||||
val ts = payload.optString("ts", "")
|
|
||||||
val ctxJson = payload.optJSONObject("context") ?: JSONObject()
|
|
||||||
|
|
||||||
val fp = fingerprint(type, message)
|
|
||||||
|
|
||||||
// ── 1. Search for existing open issue ──────────────────────────────
|
|
||||||
val searchQ = "repo:$GH_REPO is:issue is:open label:auto-report \"fp:$fp\" in:body"
|
|
||||||
val searchUrl = "https://api.github.com/search/issues?q=${java.net.URLEncoder.encode(searchQ, "UTF-8")}&per_page=1"
|
|
||||||
val searchResult = ghGet(searchUrl) ?: JSONObject()
|
|
||||||
val existingNumber = searchResult.optJSONArray("items")?.optJSONObject(0)?.optInt("number", 0)?.takeIf { it > 0 }
|
|
||||||
|
|
||||||
// ── 2. Build body ─────────────────────────────────────────────────
|
|
||||||
val ctxMd = if (ctxJson.length() > 0) "\n**Context:**\n```json\n${ctxJson.toString(2)}\n```\n" else ""
|
|
||||||
val stackMd = if (stack.isNotEmpty()) "\n**Stack trace:**\n```\n$stack\n```\n" else ""
|
|
||||||
|
|
||||||
if (existingNumber != null) {
|
|
||||||
// Comment on existing issue
|
|
||||||
val body = "### 🔁 Recurrence — $ts\n**Source:** `$source` | **Type:** `$type`\n**UA:** `$ua`\n$ctxMd$stackMd\n---\n_fp:${fp}_"
|
|
||||||
ghPost("https://api.github.com/repos/$GH_REPO/issues/$existingNumber/comments", JSONObject().put("body", body))
|
|
||||||
} else {
|
|
||||||
// Create new issue
|
|
||||||
val shortMsg = if (message.length > 70) "${message.take(70)}…" else message
|
|
||||||
val title = "[SCALE] $shortMsg"
|
|
||||||
val body = "## 🚨 Automatic Error Report\n\n**Source:** `$source` \n**Type:** `$type` \n**Reported at:** $ts \n**UA:** `$ua` \n**Version:** `$version`\n\n**Error message:**\n> $message\n$stackMd$ctxMd\n---\n<!-- auto-report fp:$fp -->\n_This issue was created automatically by EverShelf Scale Gateway error reporter. fp:`${fp}`_"
|
|
||||||
ghPost(
|
|
||||||
"https://api.github.com/repos/$GH_REPO/issues",
|
|
||||||
JSONObject()
|
|
||||||
.put("title", title)
|
|
||||||
.put("body", body)
|
|
||||||
.put("labels", JSONArray().put("auto-report").put("scale-error"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ghGet(url: String): JSONObject? = try {
|
|
||||||
val conn = URL(url).openConnection() as HttpURLConnection
|
|
||||||
conn.setRequestProperty("Authorization", "token ${ghToken()}")
|
|
||||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
|
||||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
|
||||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
|
||||||
conn.connectTimeout = 8000
|
|
||||||
conn.readTimeout = 8000
|
|
||||||
val raw = BufferedReader(InputStreamReader(conn.inputStream)).readText()
|
|
||||||
conn.disconnect()
|
|
||||||
JSONObject(raw)
|
|
||||||
} catch (e: Exception) { Log.w(TAG, "ghGet failed: ${e.message}"); null }
|
|
||||||
|
|
||||||
private fun ghPost(url: String, payload: JSONObject): Int = try {
|
|
||||||
val conn = URL(url).openConnection() as HttpURLConnection
|
|
||||||
conn.requestMethod = "POST"
|
|
||||||
conn.setRequestProperty("Authorization", "token ${ghToken()}")
|
|
||||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
|
||||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
|
||||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
|
||||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
|
||||||
conn.doOutput = true
|
|
||||||
conn.connectTimeout = 8000
|
|
||||||
conn.readTimeout = 8000
|
|
||||||
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
|
|
||||||
val code = conn.responseCode
|
|
||||||
conn.disconnect()
|
|
||||||
Log.d(TAG, "ghPost $url → HTTP $code")
|
|
||||||
code
|
|
||||||
} catch (e: Exception) { Log.w(TAG, "ghPost failed: ${e.message}"); -1 }
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package it.dadaloop.evershelf.scalegate
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import org.java_websocket.WebSocket
|
|
||||||
import org.java_websocket.handshake.ClientHandshake
|
|
||||||
import org.java_websocket.server.WebSocketServer
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.net.InetSocketAddress
|
|
||||||
import java.util.Collections
|
|
||||||
|
|
||||||
private const val TAG = "GatewayWsServer"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callbacks for the WebSocket server, dispatched on the server's internal thread.
|
|
||||||
* The caller (MainActivity) is responsible for switching to the main thread if needed.
|
|
||||||
*/
|
|
||||||
interface ServerEventListener {
|
|
||||||
fun onClientConnected(address: String)
|
|
||||||
fun onClientDisconnected(address: String)
|
|
||||||
fun onClientRequestedWeight()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket server that exposes smart-scale data to EverShelf running in a browser.
|
|
||||||
*
|
|
||||||
* Message protocol (JSON):
|
|
||||||
*
|
|
||||||
* Server -> Client:
|
|
||||||
* {"type":"status","state":"connected"|"disconnected","device":"QN-KS","battery":80}
|
|
||||||
* {"type":"weight","value":17.0,"unit":"g","stable":true,"timestamp":1712345678000}
|
|
||||||
* {"type":"pong"}
|
|
||||||
*
|
|
||||||
* Client → Server:
|
|
||||||
* {"type":"get_status"} → server responds with current status message
|
|
||||||
* {"type":"get_weight"} → server will push the next stable weight reading
|
|
||||||
* {"type":"ping"} → server responds with {"type":"pong"}
|
|
||||||
*/
|
|
||||||
class GatewayWebSocketServer(
|
|
||||||
port: Int,
|
|
||||||
private val eventListener: ServerEventListener?,
|
|
||||||
) : WebSocketServer(InetSocketAddress(port)) {
|
|
||||||
|
|
||||||
// Thread-safe set of clients waiting for the next stable weight reading
|
|
||||||
private val pendingWeightRequests: MutableSet<WebSocket> =
|
|
||||||
Collections.synchronizedSet(mutableSetOf())
|
|
||||||
|
|
||||||
// Last known scale state (to send to new clients immediately)
|
|
||||||
@Volatile private var lastStatusJson: String = buildStatusJson("disconnected", null, null)
|
|
||||||
@Volatile private var lastWeightJson: String? = null
|
|
||||||
|
|
||||||
// ─── Server lifecycle ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
Log.i(TAG, "WebSocket server started on port ${address.port}")
|
|
||||||
connectionLostTimeout = 30
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
|
|
||||||
val addr = conn.remoteSocketAddress?.toString() ?: "?"
|
|
||||||
Log.d(TAG, "Client connected: $addr")
|
|
||||||
|
|
||||||
// Immediately send current status so the web app knows the scale state
|
|
||||||
conn.send(lastStatusJson)
|
|
||||||
lastWeightJson?.let { conn.send(it) }
|
|
||||||
|
|
||||||
eventListener?.onClientConnected(addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
|
|
||||||
val addr = conn.remoteSocketAddress?.toString() ?: "?"
|
|
||||||
Log.d(TAG, "Client disconnected: $addr (code=$code)")
|
|
||||||
pendingWeightRequests.remove(conn)
|
|
||||||
eventListener?.onClientDisconnected(addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(conn: WebSocket, message: String) {
|
|
||||||
try {
|
|
||||||
val json = JSONObject(message)
|
|
||||||
when (json.optString("type")) {
|
|
||||||
"ping" -> conn.send("""{"type":"pong"}""")
|
|
||||||
"get_status" -> conn.send(lastStatusJson)
|
|
||||||
"get_weight" -> {
|
|
||||||
// Add to pending set; next stable weight will be sent to this client
|
|
||||||
pendingWeightRequests.add(conn)
|
|
||||||
eventListener?.onClientRequestedWeight()
|
|
||||||
// If we already have a recent weight, send it immediately
|
|
||||||
lastWeightJson?.let { conn.send(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Malformed message: $message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(conn: WebSocket?, ex: Exception) {
|
|
||||||
Log.e(TAG, "WebSocket error on ${conn?.remoteSocketAddress}", ex)
|
|
||||||
ErrorReporter.report(ex, "GatewayWebSocketServer.onError",
|
|
||||||
mapOf("remote_addr" to (conn?.remoteSocketAddress?.toString() ?: "null")))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Publishing API ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast scale connection status to all connected WebSocket clients.
|
|
||||||
*/
|
|
||||||
fun publishStatus(state: String, deviceName: String?, battery: Int?) {
|
|
||||||
lastStatusJson = buildStatusJson(state, deviceName, battery)
|
|
||||||
broadcast(lastStatusJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast a weight reading to all clients.
|
|
||||||
* If [stable] is true, also fulfil pending on-demand weight requests.
|
|
||||||
*/
|
|
||||||
fun publishWeight(value: Float, unit: String, stable: Boolean, battery: Int? = null) {
|
|
||||||
val json = buildWeightJson(value, unit, stable)
|
|
||||||
lastWeightJson = json
|
|
||||||
broadcast(json)
|
|
||||||
|
|
||||||
if (stable) {
|
|
||||||
synchronized(pendingWeightRequests) {
|
|
||||||
// Clients that requested on-demand readings are already served by broadcast;
|
|
||||||
// just clear the pending set.
|
|
||||||
pendingWeightRequests.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── JSON builders ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun buildStatusJson(state: String, device: String?, battery: Int?): String {
|
|
||||||
val obj = JSONObject()
|
|
||||||
obj.put("type", "status")
|
|
||||||
obj.put("state", state)
|
|
||||||
if (device != null) obj.put("device", device)
|
|
||||||
if (battery != null) obj.put("battery", battery)
|
|
||||||
return obj.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildWeightJson(value: Float, unit: String, stable: Boolean): String {
|
|
||||||
val obj = JSONObject()
|
|
||||||
obj.put("type", "weight")
|
|
||||||
// Round to 1 decimal to avoid floating point noise (e.g. 17.000001)
|
|
||||||
val rounded = Math.round(value * 10f) / 10.0
|
|
||||||
obj.put("value", rounded)
|
|
||||||
obj.put("unit", unit)
|
|
||||||
obj.put("stable", stable)
|
|
||||||
obj.put("timestamp", System.currentTimeMillis())
|
|
||||||
return obj.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,674 +0,0 @@
|
|||||||
package it.dadaloop.evershelf.scalegate
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.bluetooth.BluetoothAdapter
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.pm.PackageInstaller
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.google.android.material.button.MaterialButton
|
|
||||||
import it.dadaloop.evershelf.scalegate.databinding.ActivityMainBinding
|
|
||||||
import java.net.Inet4Address
|
|
||||||
import java.net.NetworkInterface
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
private const val WS_PORT = 8765
|
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener {
|
|
||||||
|
|
||||||
private lateinit var binding: ActivityMainBinding
|
|
||||||
private lateinit var bleManager: BleScaleManager
|
|
||||||
private var wsServer: GatewayWebSocketServer? = null
|
|
||||||
|
|
||||||
private val devices = mutableListOf<BleDeviceInfo>()
|
|
||||||
private lateinit var deviceAdapter: DeviceAdapter
|
|
||||||
|
|
||||||
private var batteryLevel: Int? = null
|
|
||||||
private val debugLines = mutableListOf<String>()
|
|
||||||
private var debugVisible = false
|
|
||||||
private var lastDebugUpdate = 0L
|
|
||||||
private val debugTimeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
|
||||||
private var isAutoReconnecting = false
|
|
||||||
// Update banner
|
|
||||||
private var pendingApkDownloadUrl = ""
|
|
||||||
private var pendingInstallFile: java.io.File? = null
|
|
||||||
private companion object {
|
|
||||||
const val MAX_DEBUG_LINES = 150
|
|
||||||
const val DEBUG_THROTTLE_MS = 200L
|
|
||||||
const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
|
|
||||||
const val APK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Permission launcher ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private val permissionLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestMultiplePermissions()
|
|
||||||
) { granted ->
|
|
||||||
if (granted.values.all { it }) {
|
|
||||||
startGatewayServer()
|
|
||||||
} else {
|
|
||||||
showDialog("Missing permissions",
|
|
||||||
"The app requires Bluetooth and Location permissions to function.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val enableBtLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { result ->
|
|
||||||
if (result.resultCode == RESULT_OK) checkPermissionsAndStart()
|
|
||||||
else showDialog("Bluetooth required", "Please enable Bluetooth to use the gateway.")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns from ACTION_MANAGE_UNKNOWN_APP_SOURCES — retry the download. */
|
|
||||||
private val installPermLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { _ ->
|
|
||||||
val url = pendingApkDownloadUrl
|
|
||||||
if (url.isNotEmpty()) triggerApkDownload(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns from system installer dialog — if not OK the install failed (signature conflict?). */
|
|
||||||
private val installConfirmLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { result ->
|
|
||||||
if (result.resultCode != RESULT_OK) {
|
|
||||||
val f = pendingInstallFile
|
|
||||||
if (f != null && f.exists()) {
|
|
||||||
runOnUiThread {
|
|
||||||
AlertDialog.Builder(this)
|
|
||||||
.setTitle("⚠️ Installazione non riuscita")
|
|
||||||
.setMessage("Se hai visto un errore di conflitto firma, devi disinstallare la versione precedente.\n\nDisinstalla ora? L'installazione ripartirà automaticamente.")
|
|
||||||
.setPositiveButton("Disinstalla") { _, _ ->
|
|
||||||
uninstallLauncher.launch(
|
|
||||||
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.setNegativeButton("Annulla", null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns from uninstall screen — auto-retry the install with the saved APK file. */
|
|
||||||
private val uninstallLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult()
|
|
||||||
) { _ ->
|
|
||||||
val f = pendingInstallFile
|
|
||||||
if (f != null && f.exists()) installApk(f)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Lifecycle ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
|
||||||
setContentView(binding.root)
|
|
||||||
|
|
||||||
bleManager = BleScaleManager(this, this)
|
|
||||||
|
|
||||||
// Initialise error reporter early so the UncaughtExceptionHandler is installed
|
|
||||||
// and any pending crash from a previous session is sent
|
|
||||||
ErrorReporter.init(this)
|
|
||||||
|
|
||||||
deviceAdapter = DeviceAdapter(devices) { info ->
|
|
||||||
bleManager.connect(info.device)
|
|
||||||
}
|
|
||||||
binding.rvDevices.apply {
|
|
||||||
layoutManager = LinearLayoutManager(this@MainActivity)
|
|
||||||
adapter = deviceAdapter
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.btnScan.setOnClickListener { startScanIfPermitted() }
|
|
||||||
binding.btnDisconnect.setOnClickListener {
|
|
||||||
bleManager.disconnect()
|
|
||||||
updateUiDisconnected()
|
|
||||||
}
|
|
||||||
binding.btnDebug.setOnClickListener {
|
|
||||||
debugVisible = !debugVisible
|
|
||||||
binding.svDebugLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
|
|
||||||
binding.btnCopyLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
|
|
||||||
binding.btnShareLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
|
|
||||||
binding.btnDebug.text = if (debugVisible) "\uD83D\uDC1B Hide Debug" else "\uD83D\uDC1B Debug"
|
|
||||||
}
|
|
||||||
binding.btnCopyLog.setOnClickListener {
|
|
||||||
val log = debugLines.joinToString("\n")
|
|
||||||
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
|
||||||
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Scale Log", log))
|
|
||||||
Toast.makeText(this, "Log copied to clipboard", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
binding.btnShareLog.setOnClickListener {
|
|
||||||
val log = debugLines.joinToString("\n")
|
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
|
||||||
type = "text/plain"
|
|
||||||
putExtra(Intent.EXTRA_SUBJECT, "EverShelf Scale Gateway - Debug Log")
|
|
||||||
putExtra(Intent.EXTRA_TEXT, log)
|
|
||||||
}
|
|
||||||
startActivity(Intent.createChooser(intent, "Share log"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show app version
|
|
||||||
try {
|
|
||||||
val pInfo = packageManager.getPackageInfo(packageName, 0)
|
|
||||||
binding.tvVersion.text = "v${pInfo.versionName} (${pInfo.longVersionCode})"
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
|
|
||||||
updateGatewayUrl()
|
|
||||||
checkPermissionsAndStart()
|
|
||||||
|
|
||||||
// Wire update banner buttons
|
|
||||||
binding.btnDismissUpdate.setOnClickListener { binding.updateBanner.visibility = View.GONE }
|
|
||||||
binding.btnInstallUpdate.setOnClickListener { triggerApkDownload(pendingApkDownloadUrl) }
|
|
||||||
|
|
||||||
// Check for a newer release (background thread, at most once every 6 h)
|
|
||||||
checkForUpdates()
|
|
||||||
|
|
||||||
// Auto-connect: if we have a saved device, start scanning with auto-connect enabled
|
|
||||||
if (bleManager.getSavedDeviceAddress() != null) {
|
|
||||||
binding.tvScanHint.visibility = View.VISIBLE
|
|
||||||
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale\u2026"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
bleManager.disconnect()
|
|
||||||
wsServer?.stop(1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Permissions & startup ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun checkPermissionsAndStart() {
|
|
||||||
val required = buildList {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
add(Manifest.permission.BLUETOOTH_SCAN)
|
|
||||||
add(Manifest.permission.BLUETOOTH_CONNECT)
|
|
||||||
} else {
|
|
||||||
add(Manifest.permission.ACCESS_FINE_LOCATION)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val missing = required.filter {
|
|
||||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
when {
|
|
||||||
missing.isNotEmpty() -> permissionLauncher.launch(missing.toTypedArray())
|
|
||||||
!isBluetoothEnabled() -> enableBtLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
|
|
||||||
else -> startGatewayServer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isBluetoothEnabled(): Boolean {
|
|
||||||
val adapter = android.bluetooth.BluetoothManager::class.java.let {
|
|
||||||
getSystemService(it)
|
|
||||||
} as? android.bluetooth.BluetoothManager
|
|
||||||
return adapter?.adapter?.isEnabled == true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startScanIfPermitted() {
|
|
||||||
if (!bleManager.hasRequiredPermissions()) {
|
|
||||||
checkPermissionsAndStart()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
devices.clear()
|
|
||||||
deviceAdapter.notifyDataSetChanged()
|
|
||||||
debugLines.clear()
|
|
||||||
binding.tvDebugLog.text = ""
|
|
||||||
binding.tvScanHint.visibility = View.VISIBLE
|
|
||||||
binding.tvScanHint.text = "Scanning for BLE scales\u2026"
|
|
||||||
binding.btnScan.isEnabled = false
|
|
||||||
bleManager.enableAutoConnect()
|
|
||||||
isAutoReconnecting = false // manual scan — stop any pending auto-reconnect cycle
|
|
||||||
bleManager.startScan()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── WebSocket gateway ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun startGatewayServer() {
|
|
||||||
if (wsServer != null) return
|
|
||||||
try {
|
|
||||||
wsServer = GatewayWebSocketServer(WS_PORT, this)
|
|
||||||
wsServer!!.start()
|
|
||||||
updateGatewayUrl()
|
|
||||||
binding.tvGatewayStatus.text = "\u2705 Gateway active on port $WS_PORT"
|
|
||||||
} catch (e: Exception) {
|
|
||||||
binding.tvGatewayStatus.text = "\u274C Failed to start gateway: ${e.message}"
|
|
||||||
ErrorReporter.report(e, "startGatewayServer", mapOf("port" to WS_PORT))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-scan if there's a saved device
|
|
||||||
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
|
|
||||||
bleManager.enableAutoConnect()
|
|
||||||
bleManager.startScan()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateGatewayUrl() {
|
|
||||||
val ip = getLocalIpAddress() ?: "—"
|
|
||||||
val url = "ws://$ip:$WS_PORT"
|
|
||||||
binding.tvGatewayUrl.text = url
|
|
||||||
binding.tvGatewayUrlHint.text = "Paste this URL in EverShelf \u2192 Settings \u2192 Smart Scale"
|
|
||||||
binding.btnCopyUrl.setOnClickListener {
|
|
||||||
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
|
||||||
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Gateway URL", url))
|
|
||||||
binding.btnCopyUrl.text = "\u2705 Copied!"
|
|
||||||
binding.btnCopyUrl.postDelayed({ binding.btnCopyUrl.text = "\uD83D\uDCCB Copy URL" }, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── BleScaleListener ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
override fun onDeviceFound(info: BleDeviceInfo) {
|
|
||||||
if (devices.none { it.device.address == info.device.address }) {
|
|
||||||
// Insert keeping descending scaleScore order (scale-likely devices first)
|
|
||||||
val insertAt = devices.indexOfFirst { it.scaleScore < info.scaleScore }
|
|
||||||
.let { if (it < 0) devices.size else it }
|
|
||||||
devices.add(insertAt, info)
|
|
||||||
deviceAdapter.notifyItemInserted(insertAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConnecting(device: BluetoothDevice) {
|
|
||||||
val name = try { device.name ?: device.address } catch (e: SecurityException) { device.address }
|
|
||||||
binding.tvScaleStatus.text = "\u23f3 Connecting to $name\u2026"
|
|
||||||
binding.tvWeight.text = "— — —"
|
|
||||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_orange_light))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConnected(deviceName: String) {
|
|
||||||
isAutoReconnecting = false
|
|
||||||
binding.tvScaleStatus.text = "\u2705 Connected: $deviceName"
|
|
||||||
binding.tvWeight.text = "Waiting for weight\u2026"
|
|
||||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_green_light))
|
|
||||||
binding.btnDisconnect.visibility = View.VISIBLE
|
|
||||||
binding.rvDevices.visibility = View.GONE
|
|
||||||
binding.btnScan.visibility = View.GONE
|
|
||||||
binding.tvScanHint.visibility = View.GONE
|
|
||||||
wsServer?.publishStatus("connected", deviceName, batteryLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDisconnected() {
|
|
||||||
wsServer?.publishStatus("disconnected", null, null)
|
|
||||||
updateUiDisconnected()
|
|
||||||
// Auto-reconnect: if a saved device exists, restart scan after a short delay.
|
|
||||||
// This handles the scale turning off by itself (auto-off) — when it powers
|
|
||||||
// back on it will start advertising again and we will pick it up.
|
|
||||||
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
|
|
||||||
isAutoReconnecting = true
|
|
||||||
binding.tvScanHint.visibility = View.VISIBLE
|
|
||||||
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale in 5 s\u2026"
|
|
||||||
binding.root.postDelayed({
|
|
||||||
if (!bleManager.isConnected && isAutoReconnecting) {
|
|
||||||
bleManager.enableAutoConnect()
|
|
||||||
bleManager.startScan()
|
|
||||||
}
|
|
||||||
}, 5_000L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWeightReceived(reading: WeightReading) {
|
|
||||||
val displayValue = if (reading.value % 1f == 0f) reading.value.toInt().toString()
|
|
||||||
else "%.1f".format(reading.value)
|
|
||||||
binding.tvWeight.text = "$displayValue ${reading.unit}"
|
|
||||||
|
|
||||||
if (reading.stable) {
|
|
||||||
binding.tvWeightHint.text = "\u2713 Stable reading"
|
|
||||||
} else {
|
|
||||||
binding.tvWeightHint.text = "\u23f3 Measuring\u2026"
|
|
||||||
}
|
|
||||||
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, batteryLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBatteryReceived(level: Int) {
|
|
||||||
batteryLevel = level
|
|
||||||
binding.tvBattery.text = "🔋 $level%"
|
|
||||||
binding.tvBattery.visibility = View.VISIBLE
|
|
||||||
wsServer?.publishStatus("connected", binding.tvScaleStatus.text.toString()
|
|
||||||
.removePrefix("\u2705 Connected: "), level)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(message: String) {
|
|
||||||
binding.tvScaleStatus.text = "❌ $message"
|
|
||||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_red_light))
|
|
||||||
ErrorReporter.reportMessage(
|
|
||||||
type = "ble-error",
|
|
||||||
message = message,
|
|
||||||
extra = mapOf("connected_device" to (bleManager.getSavedDeviceAddress() ?: "none"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onScanStopped() {
|
|
||||||
binding.btnScan.isEnabled = true
|
|
||||||
if (isAutoReconnecting && !bleManager.isConnected && bleManager.getSavedDeviceAddress() != null) {
|
|
||||||
// Scale not found yet — retry scan after 10 s indefinitely until reconnected
|
|
||||||
binding.tvScanHint.visibility = View.VISIBLE
|
|
||||||
binding.tvScanHint.text = "\uD83D\uDD04 Bilancia non trovata, riprovo tra 10 s\u2026"
|
|
||||||
binding.root.postDelayed({
|
|
||||||
if (!bleManager.isConnected && isAutoReconnecting) {
|
|
||||||
binding.tvScanHint.text = "\uD83D\uDD04 Cerco la bilancia\u2026"
|
|
||||||
bleManager.enableAutoConnect()
|
|
||||||
bleManager.startScan()
|
|
||||||
}
|
|
||||||
}, 10_000L)
|
|
||||||
} else if (devices.isEmpty()) {
|
|
||||||
binding.tvScanHint.text = "No scale found. Make sure it's on, then scan again."
|
|
||||||
} else {
|
|
||||||
binding.tvScanHint.text = "Tap a scale to connect."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDebugEvent(message: String) {
|
|
||||||
runOnUiThread {
|
|
||||||
val ts = debugTimeFmt.format(Date())
|
|
||||||
debugLines.add("[$ts] $message")
|
|
||||||
// Keep only last MAX_DEBUG_LINES
|
|
||||||
while (debugLines.size > MAX_DEBUG_LINES) debugLines.removeAt(0)
|
|
||||||
// Throttle UI updates to avoid freezing
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (now - lastDebugUpdate >= DEBUG_THROTTLE_MS) {
|
|
||||||
lastDebugUpdate = now
|
|
||||||
binding.tvDebugLog.text = debugLines.joinToString("\n")
|
|
||||||
if (debugVisible) {
|
|
||||||
binding.svDebugLog.post { binding.svDebugLog.fullScroll(View.FOCUS_DOWN) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ServerEventListener ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
override fun onClientConnected(address: String) {
|
|
||||||
runOnUiThread {
|
|
||||||
binding.tvClientCount.text = "\uD83C\uDF10 Client connected: $address"
|
|
||||||
binding.tvClientCount.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClientDisconnected(address: String) {
|
|
||||||
runOnUiThread {
|
|
||||||
binding.tvClientCount.visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClientRequestedWeight() { /* Nothing extra needed */ }
|
|
||||||
|
|
||||||
// ─── UI helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun updateUiDisconnected() {
|
|
||||||
binding.tvScaleStatus.text = "\u26a1 Ready \u2014 scan for a scale"
|
|
||||||
binding.tvWeight.text = "— — —"
|
|
||||||
binding.tvWeightHint.text = ""
|
|
||||||
binding.tvBattery.visibility = View.GONE
|
|
||||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.darker_gray))
|
|
||||||
binding.btnDisconnect.visibility = View.GONE
|
|
||||||
binding.rvDevices.visibility = View.VISIBLE
|
|
||||||
binding.btnScan.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getLocalIpAddress(): String? {
|
|
||||||
return try {
|
|
||||||
NetworkInterface.getNetworkInterfaces().toList()
|
|
||||||
.flatMap { it.inetAddresses.toList() }
|
|
||||||
.filterIsInstance<Inet4Address>()
|
|
||||||
.firstOrNull { !it.isLoopbackAddress }
|
|
||||||
?.hostAddress
|
|
||||||
} catch (e: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showDialog(title: String, message: String) {
|
|
||||||
AlertDialog.Builder(this)
|
|
||||||
.setTitle(title)
|
|
||||||
.setMessage(message)
|
|
||||||
.setPositiveButton("OK", null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Update check ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun checkForUpdates() {
|
|
||||||
Thread {
|
|
||||||
try {
|
|
||||||
val conn = java.net.URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
|
|
||||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
|
||||||
conn.connectTimeout = 5000
|
|
||||||
conn.readTimeout = 5000
|
|
||||||
val body = conn.inputStream.bufferedReader().readText()
|
|
||||||
conn.disconnect()
|
|
||||||
val json = JSONObject(body)
|
|
||||||
val latestTag = json.optString("tag_name", "").ifEmpty { return@Thread }
|
|
||||||
val current = try { packageManager.getPackageInfo(packageName, 0).versionName ?: "" } catch (_: Exception) { "" }
|
|
||||||
val norm = { v: String -> v.trimStart('v') }
|
|
||||||
val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*"))
|
|
||||||
|
|
||||||
// Find scale-gateway APK in release assets
|
|
||||||
var apkUrl = ""
|
|
||||||
val assets = json.optJSONArray("assets")
|
|
||||||
if (assets != null) {
|
|
||||||
for (i in 0 until assets.length()) {
|
|
||||||
val a = assets.getJSONObject(i)
|
|
||||||
val name = a.optString("name", "").lowercase()
|
|
||||||
val url = a.optString("browser_download_url", "")
|
|
||||||
if ((name.contains("gateway") || name.contains("scale")) && url.isNotEmpty()) {
|
|
||||||
apkUrl = url; break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Only show banner if the release actually contains our APK
|
|
||||||
if (apkUrl.isEmpty()) return@Thread
|
|
||||||
|
|
||||||
// Proper semver comparison: only update if remote is strictly newer
|
|
||||||
fun semverNewer(remote: String, local: String): Boolean {
|
|
||||||
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
|
||||||
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
|
||||||
val len = maxOf(r.size, l.size)
|
|
||||||
for (i in 0 until len) {
|
|
||||||
val rv = r.getOrElse(i) { 0 }
|
|
||||||
val lv = l.getOrElse(i) { 0 }
|
|
||||||
if (rv != lv) return rv > lv
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.isEmpty()) return@Thread
|
|
||||||
if (isSemver && !semverNewer(norm(latestTag), norm(current))) return@Thread
|
|
||||||
|
|
||||||
val label = if (isSemver) "$current → $latestTag" else latestTag
|
|
||||||
val msg = "⬆️ Scale Gateway $label"
|
|
||||||
runOnUiThread { showNativeUpdateBanner(msg, apkUrl) }
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showNativeUpdateBanner(message: String, apkUrl: String) {
|
|
||||||
pendingApkDownloadUrl = apkUrl
|
|
||||||
binding.tvUpdateMessage.text = message
|
|
||||||
binding.updateBanner.visibility = View.VISIBLE
|
|
||||||
binding.updateBanner.postDelayed({ binding.updateBanner.visibility = View.GONE }, 30_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun triggerApkDownload(apkUrl: String) {
|
|
||||||
if (apkUrl.isEmpty()) return
|
|
||||||
try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
|
||||||
!packageManager.canRequestPackageInstalls()) {
|
|
||||||
pendingApkDownloadUrl = apkUrl // remember for retry
|
|
||||||
installPermLauncher.launch(
|
|
||||||
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
|
|
||||||
)
|
|
||||||
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi torna qui", Toast.LENGTH_LONG).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Download to app-private external dir — no storage permission needed
|
|
||||||
val destDir = getExternalFilesDir(null) ?: filesDir
|
|
||||||
val destFile = java.io.File(destDir, "evershelf-scale-update.apk")
|
|
||||||
pendingInstallFile = destFile
|
|
||||||
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
|
|
||||||
val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
|
|
||||||
setTitle("EverShelf Scale Gateway — Aggiornamento")
|
|
||||||
setDescription("Scaricamento aggiornamento…")
|
|
||||||
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
|
||||||
setDestinationUri(Uri.fromFile(destFile))
|
|
||||||
setMimeType("application/vnd.android.package-archive")
|
|
||||||
}
|
|
||||||
val downloadId = dm.enqueue(req)
|
|
||||||
Toast.makeText(this, "Download avviato…", Toast.LENGTH_SHORT).show()
|
|
||||||
val receiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
|
||||||
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
|
|
||||||
if (id != downloadId) return
|
|
||||||
unregisterReceiver(this)
|
|
||||||
val q = DownloadManager.Query().setFilterById(downloadId)
|
|
||||||
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
|
|
||||||
var ok = false
|
|
||||||
if (c.moveToFirst()) {
|
|
||||||
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
|
||||||
ok = (status == DownloadManager.STATUS_SUCCESSFUL)
|
|
||||||
}
|
|
||||||
c.close()
|
|
||||||
if (ok) installApk(destFile)
|
|
||||||
else runOnUiThread {
|
|
||||||
Toast.makeText(this@MainActivity, "Download fallito, riprova", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
// RECEIVER_EXPORTED required: ACTION_DOWNLOAD_COMPLETE is sent by the system DownloadManager
|
|
||||||
// (an external process), so NOT_EXPORTED would silently block the broadcast on API 33+.
|
|
||||||
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_EXPORTED)
|
|
||||||
} else {
|
|
||||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
|
||||||
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Toast.makeText(this, "Errore download: ${e.message}", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun installApk(file: java.io.File) {
|
|
||||||
if (!file.exists() || file.length() == 0L) {
|
|
||||||
runOnUiThread { Toast.makeText(this, "File APK non trovato", Toast.LENGTH_LONG).show() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val pi = packageManager.packageInstaller
|
|
||||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
|
||||||
params.setAppPackageName(packageName)
|
|
||||||
val sessionId = pi.createSession(params)
|
|
||||||
pi.openSession(sessionId).use { session ->
|
|
||||||
file.inputStream().use { input ->
|
|
||||||
session.openWrite("package", 0, file.length()).use { out ->
|
|
||||||
input.copyTo(out)
|
|
||||||
session.fsync(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val action = "it.dadaloop.evershelf.scalegate.INSTALL_RESULT_$sessionId"
|
|
||||||
val resultReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
|
||||||
unregisterReceiver(this)
|
|
||||||
val status = intent?.getIntExtra(
|
|
||||||
PackageInstaller.EXTRA_STATUS,
|
|
||||||
PackageInstaller.STATUS_FAILURE
|
|
||||||
) ?: PackageInstaller.STATUS_FAILURE
|
|
||||||
when (status) {
|
|
||||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
|
||||||
// Use launcher so we get notified if system installer fails
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
|
||||||
intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
|
||||||
else intent?.getParcelableExtra(Intent.EXTRA_INTENT)
|
|
||||||
if (confirmIntent != null) installConfirmLauncher.launch(confirmIntent)
|
|
||||||
}
|
|
||||||
PackageInstaller.STATUS_SUCCESS ->
|
|
||||||
runOnUiThread { Toast.makeText(this@MainActivity, "✅ Aggiornamento installato", Toast.LENGTH_SHORT).show() }
|
|
||||||
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
|
|
||||||
PackageInstaller.STATUS_FAILURE_CONFLICT -> {
|
|
||||||
runOnUiThread {
|
|
||||||
AlertDialog.Builder(this@MainActivity)
|
|
||||||
.setTitle("⚠️ Conflitto firma APK")
|
|
||||||
.setMessage("L'app installata usa una firma diversa.\n\nDisinstalla la versione precedente: al termine l'installazione riparte automaticamente.")
|
|
||||||
.setPositiveButton("Disinstalla") { _, _ ->
|
|
||||||
uninstallLauncher.launch(
|
|
||||||
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.setNegativeButton("Annulla", null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val msg = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
|
||||||
?: "status=$status"
|
|
||||||
runOnUiThread { Toast.makeText(this@MainActivity, "Installazione: $msg", Toast.LENGTH_LONG).show() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
|
||||||
RECEIVER_NOT_EXPORTED else 0
|
|
||||||
registerReceiver(resultReceiver, IntentFilter(action), flags)
|
|
||||||
val pi2 = PendingIntent.getBroadcast(
|
|
||||||
this, sessionId,
|
|
||||||
Intent(action).setPackage(packageName),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
session.commit(pi2.intentSender)
|
|
||||||
}
|
|
||||||
Toast.makeText(this, "Installazione in corso…", Toast.LENGTH_SHORT).show()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
runOnUiThread { Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── RecyclerView adapter ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
inner class DeviceAdapter(
|
|
||||||
private val items: List<BleDeviceInfo>,
|
|
||||||
private val onClick: (BleDeviceInfo) -> Unit,
|
|
||||||
) : RecyclerView.Adapter<DeviceAdapter.VH>() {
|
|
||||||
|
|
||||||
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
|
||||||
val tvName: TextView = view.findViewById(R.id.tv_device_name)
|
|
||||||
val tvAddr: TextView = view.findViewById(R.id.tv_device_addr)
|
|
||||||
val tvRssi: TextView = view.findViewById(R.id.tv_device_rssi)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
|
||||||
val view = LayoutInflater.from(parent.context)
|
|
||||||
.inflate(R.layout.item_device, parent, false)
|
|
||||||
return VH(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
|
||||||
val info = items[position]
|
|
||||||
holder.tvName.text = info.name
|
|
||||||
holder.tvAddr.text = info.device.address
|
|
||||||
holder.tvRssi.text = info.proximity
|
|
||||||
holder.itemView.setOnClickListener { onClick(info) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount() = items.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
package it.dadaloop.evershelf.scalegate
|
|
||||||
|
|
||||||
import android.bluetooth.BluetoothGattCharacteristic
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
// --- Data model ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single weight reading from a BLE scale.
|
|
||||||
* [value] is in the scale's current display unit (grams, oz, ml, lb).
|
|
||||||
* [unit] is "g", "oz", "ml", or "lb".
|
|
||||||
*/
|
|
||||||
data class WeightReading(
|
|
||||||
val value: Float,
|
|
||||||
val unit: String,
|
|
||||||
val stable: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- UUIDs ---
|
|
||||||
|
|
||||||
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
|
||||||
|
|
||||||
object BleUuids {
|
|
||||||
// BLE SIG Weight Scale (some kitchen scales use this)
|
|
||||||
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
|
|
||||||
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
|
|
||||||
|
|
||||||
// Battery
|
|
||||||
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
|
||||||
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
|
|
||||||
|
|
||||||
// Common vendor services used by kitchen scales
|
|
||||||
val FFE0 = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
|
|
||||||
val FFE1 = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb")
|
|
||||||
val FFF0 = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb")
|
|
||||||
val FFF1 = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb")
|
|
||||||
val FFF4 = UUID.fromString("0000fff4-0000-1000-8000-00805f9b34fb")
|
|
||||||
|
|
||||||
// Acaia / Brewista coffee scales
|
|
||||||
val ACAIA_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
|
|
||||||
val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
|
|
||||||
|
|
||||||
// QN/Yolanda food scale secondary service (QN-KS, etc.)
|
|
||||||
val QN_AE00 = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
|
|
||||||
val QN_AE02 = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Food scale protocol parser ---
|
|
||||||
|
|
||||||
object ScaleProtocol {
|
|
||||||
|
|
||||||
// Plausible kitchen scale range
|
|
||||||
private const val MAX_GRAMS = 15000f
|
|
||||||
private const val MIN_GRAMS = 0.5f // allow tare/small values
|
|
||||||
|
|
||||||
fun resetState() { /* reserved for future use */ }
|
|
||||||
|
|
||||||
fun parse(
|
|
||||||
char: BluetoothGattCharacteristic,
|
|
||||||
data: ByteArray,
|
|
||||||
debug: ((String) -> Unit)? = null,
|
|
||||||
): WeightReading? {
|
|
||||||
if (data.size < 2) {
|
|
||||||
debug?.invoke("skip: packet too short (" + data.size + "B)")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// UUID-specific parsers
|
|
||||||
when (char.uuid) {
|
|
||||||
BleUuids.WEIGHT_MEASUREMENT_CHAR -> return parseSigWeight(data, debug)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QN/Yolanda food scale (QN-KS, BC-KS, etc.):
|
|
||||||
// 18-byte frame starting with 0x10 0x12 on FFF1
|
|
||||||
if (data.size == 18
|
|
||||||
&& (data[0].toInt() and 0xFF) == 0x10
|
|
||||||
&& (data[1].toInt() and 0xFF) == 0x12) {
|
|
||||||
return parseQNFood(data, debug)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseGeneric(data, debug)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// BLE SIG 0x2A9D Weight Measurement
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
private fun parseSigWeight(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
|
||||||
if (data.size < 3) return null
|
|
||||||
val flags = data[0].toInt() and 0xFF
|
|
||||||
val isImperial = (flags and 0x01) != 0
|
|
||||||
val raw = u16le(data, 1)
|
|
||||||
|
|
||||||
return if (isImperial) {
|
|
||||||
val lb = raw * 0.01f
|
|
||||||
debug?.invoke("SIG 2A9D: raw=$raw -> ${lb}lb")
|
|
||||||
if (lb < 0.01f || lb > 33f) null
|
|
||||||
else WeightReading(lb, "lb", stable = true)
|
|
||||||
} else {
|
|
||||||
val g = raw * 5f // 0.005 kg resolution = 5 g/unit
|
|
||||||
debug?.invoke("SIG 2A9D: raw=$raw -> ${g}g")
|
|
||||||
if (g < MIN_GRAMS || g > MAX_GRAMS) null
|
|
||||||
else WeightReading(g, "g", stable = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// QN / Yolanda food scale (QN-KS, BC-KS, YolandaKS, ...)
|
|
||||||
//
|
|
||||||
// 18-byte notification on service 0xFFF0, char 0xFFF1:
|
|
||||||
// [0x10][0x12][00][??][unit][02][05][01][flags][w_hi][w_lo][7E][1F][02][58][02][01][crc]
|
|
||||||
// index: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
|
||||||
//
|
|
||||||
// weight = u16BE(data, 9) / 10.0 (0.1-unit resolution)
|
|
||||||
// unit = byte[4]: 0x01=g, 0x02=oz, 0x03=ml(water), 0x04=ml(milk)
|
|
||||||
// stable = bit3 of byte[8] != 0 (0xF8=stable, 0xF0=settling)
|
|
||||||
// crc = sum(bytes[0..16]) mod 256
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
|
||||||
// Verify checksum
|
|
||||||
val calc = data.take(17).sumOf { it.toInt() and 0xFF } and 0xFF
|
|
||||||
if (calc != (data[17].toInt() and 0xFF)) {
|
|
||||||
debug?.invoke("QN-KS: CRC mismatch (calc=0x%02X got=0x%02X)".format(calc, data[17].toInt() and 0xFF))
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val rawValue = u16be(data, 9)
|
|
||||||
val stable = (data[8].toInt() and 0x08) != 0
|
|
||||||
val unit = when (data[4].toInt() and 0xFF) {
|
|
||||||
0x01 -> "g"
|
|
||||||
0x02 -> "oz"
|
|
||||||
0x03 -> "ml" // water mode
|
|
||||||
0x04 -> "ml" // milk mode
|
|
||||||
else -> "g"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolution is 0.1 unit (e.g. 170 raw = 17.0 g, 195 raw = 19.5 g)
|
|
||||||
val value = rawValue / 10f
|
|
||||||
|
|
||||||
debug?.invoke("QN-KS: ${value}${unit} stable=$stable (raw=$rawValue unit_byte=0x%02X)".format(data[4].toInt() and 0xFF))
|
|
||||||
|
|
||||||
if (rawValue == 0) return null
|
|
||||||
// Convert to grams for range check
|
|
||||||
val valueG = if (unit == "oz") value * 28.3495f else value
|
|
||||||
if (valueG < MIN_GRAMS || valueG > MAX_GRAMS) return null
|
|
||||||
|
|
||||||
return WeightReading(value, unit, stable)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Generic fallback parser
|
|
||||||
// Tries common frame layouts used by many BLE kitchen scales.
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
|
||||||
if (data.size < 3) {
|
|
||||||
debug?.invoke("generic: skip short packet (" + data.size + "B)")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
data class C(val pos: Int, val be: Boolean, val div: Float, val label: String)
|
|
||||||
|
|
||||||
val candidates = listOf(
|
|
||||||
// Direct grams (1g resolution)
|
|
||||||
C(1, false, 1f, "pos1 LE g"),
|
|
||||||
C(1, true, 1f, "pos1 BE g"),
|
|
||||||
C(2, false, 1f, "pos2 LE g"),
|
|
||||||
C(2, true, 1f, "pos2 BE g"),
|
|
||||||
C(3, false, 1f, "pos3 LE g"),
|
|
||||||
C(3, true, 1f, "pos3 BE g"),
|
|
||||||
// 0.1g resolution (high-precision scales)
|
|
||||||
C(1, false, 10f, "pos1 LE 0.1g"),
|
|
||||||
C(1, true, 10f, "pos1 BE 0.1g"),
|
|
||||||
C(2, false, 10f, "pos2 LE 0.1g"),
|
|
||||||
C(2, true, 10f, "pos2 BE 0.1g"),
|
|
||||||
C(3, false, 10f, "pos3 LE 0.1g"),
|
|
||||||
C(3, true, 10f, "pos3 BE 0.1g"),
|
|
||||||
// 0.5g resolution
|
|
||||||
C(1, false, 2f, "pos1 LE 0.5g"),
|
|
||||||
C(1, true, 2f, "pos1 BE 0.5g"),
|
|
||||||
// Raw = centgrams (raw*10 = g)
|
|
||||||
C(1, false, 0.1f, "pos1 LE cg"),
|
|
||||||
C(1, true, 0.1f, "pos1 BE cg"),
|
|
||||||
C(3, false, 0.1f, "pos3 LE cg"),
|
|
||||||
C(3, true, 0.1f, "pos3 BE cg"),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (c in candidates) {
|
|
||||||
if (c.pos + 1 >= data.size) continue
|
|
||||||
val raw = if (c.be) u16be(data, c.pos) else u16le(data, c.pos)
|
|
||||||
if (raw == 0) continue
|
|
||||||
val g = raw / c.div
|
|
||||||
if (g in MIN_GRAMS..MAX_GRAMS) {
|
|
||||||
debug?.invoke("generic [${c.label}]: raw=$raw -> ${g}g (unstable)")
|
|
||||||
return WeightReading(g, "g", stable = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
debug?.invoke("generic: no valid candidate in " + data.size + " bytes")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
private fun u16le(b: ByteArray, off: Int): Int =
|
|
||||||
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
|
|
||||||
|
|
||||||
private fun u16be(b: ByteArray, off: Int): Int =
|
|
||||||
((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#6C63FF"
|
|
||||||
android:pathData="M0,0h108v108H0z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M54,30L54,70 M40,38L68,38 M36,54L44,38 M64,54L72,38 M36,54C36,56 44,56 44,54 M64,54C64,56 72,56 72,54" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="#F3F4F6">
|
|
||||||
|
|
||||||
<!-- ── Update banner (shown at the TOP when a new version is available) ─ -->
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/updateBanner"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:background="#1e293b"
|
|
||||||
android:paddingStart="16dp"
|
|
||||||
android:paddingEnd="8dp"
|
|
||||||
android:paddingTop="10dp"
|
|
||||||
android:paddingBottom="10dp"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:visibility="gone">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tvUpdateMessage"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:textColor="#fbbf24"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:text="" />
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/btnInstallUpdate"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:text="⬇ Scarica"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="#1e293b"
|
|
||||||
android:backgroundTint="#fbbf24"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/btnDismissUpdate"
|
|
||||||
android:layout_width="36dp"
|
|
||||||
android:layout_height="36dp"
|
|
||||||
android:layout_marginStart="4dp"
|
|
||||||
android:text="✕"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:textColor="#94a3b8"
|
|
||||||
android:backgroundTint="@android:color/transparent"
|
|
||||||
style="@style/Widget.MaterialComponents.Button.TextButton" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="16dp">
|
|
||||||
|
|
||||||
<!-- ── Header ─────────────────────────────────────────────────────── -->
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="⚖️ EverShelf Scale Gateway"
|
|
||||||
android:textSize="22sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="#1E293B"
|
|
||||||
android:paddingBottom="4dp" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:paddingBottom="16dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="Connect your smart scale to EverShelf via Bluetooth"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textColor="#64748B" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_version"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="v?.?.?"
|
|
||||||
android:textSize="11sp"
|
|
||||||
android:textColor="#94A3B8"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:gravity="end" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!-- ── Gateway URL card ───────────────────────────────────────────── -->
|
|
||||||
<androidx.cardview.widget.CardView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="12dp"
|
|
||||||
app:cardCornerRadius="12dp"
|
|
||||||
app:cardElevation="2dp"
|
|
||||||
app:cardBackgroundColor="#EFF6FF">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="16dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="🌐 Gateway URL (paste into EverShelf)"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="#64748B"
|
|
||||||
android:paddingBottom="4dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_gateway_url"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="ws://…:8765"
|
|
||||||
android:textSize="18sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="#1D4ED8"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:paddingBottom="4dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_gateway_url_hint"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Settings → Smart Scale"
|
|
||||||
android:textSize="11sp"
|
|
||||||
android:textColor="#94A3B8"
|
|
||||||
android:paddingBottom="8dp" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_copy_url"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="📋 Copy URL"
|
|
||||||
android:backgroundTint="#1D4ED8"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
</androidx.cardview.widget.CardView>
|
|
||||||
|
|
||||||
<!-- ── Gateway status ────────────────────────────────────────────── -->
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_gateway_status"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="⏳ Starting gateway…"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textColor="#64748B"
|
|
||||||
android:paddingBottom="4dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_client_count"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text=""
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="#059669"
|
|
||||||
android:paddingBottom="12dp"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
<!-- ── Scale connection card ──────────────────────────────────────── -->
|
|
||||||
<androidx.cardview.widget.CardView
|
|
||||||
android:id="@+id/card_connection"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="12dp"
|
|
||||||
app:cardCornerRadius="12dp"
|
|
||||||
app:cardElevation="2dp"
|
|
||||||
app:cardBackgroundColor="@android:color/darker_gray">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="16dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_scale_status"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="⚡ Ready — scan for a scale"
|
|
||||||
android:textSize="15sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:paddingBottom="8dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_weight"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="— — —"
|
|
||||||
android:textSize="46sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:gravity="center"
|
|
||||||
android:paddingTop="8dp"
|
|
||||||
android:paddingBottom="4dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_weight_hint"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text=""
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="#E2E8F0"
|
|
||||||
android:gravity="center"
|
|
||||||
android:paddingBottom="8dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_battery"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text=""
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="#E2E8F0"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_disconnect"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="🔌 Disconnect scale"
|
|
||||||
android:backgroundTint="#EF4444"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:visibility="gone"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
</androidx.cardview.widget.CardView>
|
|
||||||
|
|
||||||
<!-- ── Scan controls ──────────────────────────────────────────────── -->
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_scan"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="🔍 Scan for Bluetooth Scales"
|
|
||||||
android:backgroundTint="#7C3AED"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:layout_marginBottom="8dp"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:layout_marginBottom="8dp">
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_debug"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="\uD83D\uDC1B Debug"
|
|
||||||
android:backgroundTint="#374151"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:layout_marginEnd="4dp"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_copy_log"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="\uD83D\uDCCB"
|
|
||||||
android:backgroundTint="#374151"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:minWidth="48dp"
|
|
||||||
android:layout_marginEnd="4dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_share_log"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="\uD83D\uDCE4"
|
|
||||||
android:backgroundTint="#374151"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:minWidth="48dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
style="@style/Widget.MaterialComponents.Button" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
android:id="@+id/sv_debug_log"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="220dp"
|
|
||||||
android:layout_marginBottom="12dp"
|
|
||||||
android:background="#111827"
|
|
||||||
android:visibility="gone">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_debug_log"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text=""
|
|
||||||
android:textSize="10sp"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:textColor="#4ADE80"
|
|
||||||
android:padding="8dp" />
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_scan_hint"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Press to scan for nearby BLE scales.\nMake sure the scale is turned on."
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="#64748B"
|
|
||||||
android:paddingBottom="12dp"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
<!-- ── Device list ─────────────────────────────────────────────────── -->
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/rv_devices"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:nestedScrollingEnabled="false" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
</ScrollView>
|
|
||||||
</LinearLayout>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="8dp"
|
|
||||||
app:cardCornerRadius="10dp"
|
|
||||||
app:cardElevation="1dp"
|
|
||||||
app:cardBackgroundColor="#FFFFFF">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:padding="14dp"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="32dp"
|
|
||||||
android:layout_height="32dp"
|
|
||||||
android:text="⚖️"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:gravity="center"
|
|
||||||
android:layout_marginEnd="12dp" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_device_name"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="15sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="#1E293B" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_device_addr"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:textColor="#94A3B8" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_device_rssi"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp"
|
|
||||||
android:textColor="#64748B" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
</androidx.cardview.widget.CardView>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 122 B |
|
Before Width: | Height: | Size: 122 B |
|
Before Width: | Height: | Size: 221 B |
|
Before Width: | Height: | Size: 221 B |
|
Before Width: | Height: | Size: 413 B |
|
Before Width: | Height: | Size: 413 B |
|
Before Width: | Height: | Size: 546 B |
|
Before Width: | Height: | Size: 546 B |
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="purple_500">#FF6200EE</color>
|
|
||||||
<color name="purple_700">#FF3700B3</color>
|
|
||||||
<color name="teal_200">#FF03DAC5</color>
|
|
||||||
<color name="white">#FFFFFFFF</color>
|
|
||||||
<color name="black">#FF000000</color>
|
|
||||||
<color name="accent">#7C3AED</color>
|
|
||||||
<color name="green">#059669</color>
|
|
||||||
<color name="red">#EF4444</color>
|
|
||||||
<color name="blue">#1D4ED8</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">EverShelf Scale Gateway</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths>
|
|
||||||
<!-- App-private external dir: no storage permission needed -->
|
|
||||||
<external-files-path name="apk_downloads" path="." />
|
|
||||||
</paths>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// Top-level build file
|
|
||||||
plugins {
|
|
||||||
id("com.android.application") version "8.2.2" apply false
|
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
android.useAndroidX=true
|
|
||||||
android.enableJetifier=true
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
|
||||||
networkTimeout=10000
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dependencyResolutionManagement {
|
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootProject.name = "EverShelf Scale Gateway"
|
|
||||||
include(":app")
|
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
<!-- Title — left-aligned; grows to fill space -->
|
<!-- Title — left-aligned; grows to fill space -->
|
||||||
<div class="header-title-wrap">
|
<div class="header-title-wrap">
|
||||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.12</span>
|
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.13</span>
|
||||||
</h1>
|
</h1>
|
||||||
<!-- Update badge — shown alongside title, never replaces it -->
|
<!-- Update badge — shown alongside title, never replaces it -->
|
||||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||||
@@ -407,7 +407,7 @@
|
|||||||
<div class="use-inventory-info" id="use-inventory-info"></div>
|
<div class="use-inventory-info" id="use-inventory-info"></div>
|
||||||
<div id="use-expiry-hint" style="display:none"></div>
|
<div id="use-expiry-hint" style="display:none"></div>
|
||||||
<form class="form" onsubmit="submitUse(event)">
|
<form class="form" onsubmit="submitUse(event)">
|
||||||
<div class="form-group">
|
<div class="form-group" id="use-location-group">
|
||||||
<label>📍 Da dove?</label>
|
<label>📍 Da dove?</label>
|
||||||
<div class="location-selector" id="use-location-selector">
|
<div class="location-selector" id="use-location-selector">
|
||||||
<button type="button" class="loc-btn active" onclick="selectUseLocation(this, 'dispensa')">🗄️ Dispensa</button>
|
<button type="button" class="loc-btn active" onclick="selectUseLocation(this, 'dispensa')">🗄️ Dispensa</button>
|
||||||
@@ -1522,6 +1522,7 @@
|
|||||||
<button class="cooking-tts-btn" id="cooking-tts-btn" onclick="toggleCookingTTS()" title="Leggi ad alta voce">🔊</button>
|
<button class="cooking-tts-btn" id="cooking-tts-btn" onclick="toggleCookingTTS()" title="Leggi ad alta voce">🔊</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="cooking-timers-bar" class="cooking-timers-bar" style="display:none"></div>
|
<div id="cooking-timers-bar" class="cooking-timers-bar" style="display:none"></div>
|
||||||
|
<div id="cooking-tools-bar" class="cooking-tools-bar" style="display:none"></div>
|
||||||
<div class="cooking-body">
|
<div class="cooking-body">
|
||||||
<div class="cooking-step-header">
|
<div class="cooking-step-header">
|
||||||
<div class="cooking-step-num" id="cooking-step-num">1 / 1</div>
|
<div class="cooking-step-num" id="cooking-step-num">1 / 1</div>
|
||||||
|
|||||||
@@ -341,6 +341,7 @@
|
|||||||
"regenerate": "🔄 Noch eins generieren",
|
"regenerate": "🔄 Noch eins generieren",
|
||||||
"close_btn": "✅ Schließen",
|
"close_btn": "✅ Schließen",
|
||||||
"ingredients_title": "🧾 Zutaten",
|
"ingredients_title": "🧾 Zutaten",
|
||||||
|
"tools_title": "Benötigte Geräte",
|
||||||
"steps_title": "👨🍳 Zubereitung",
|
"steps_title": "👨🍳 Zubereitung",
|
||||||
"no_steps": "Keine Zubereitungsschritte verfügbar",
|
"no_steps": "Keine Zubereitungsschritte verfügbar",
|
||||||
"generate_error": "Fehler bei der Generierung",
|
"generate_error": "Fehler bei der Generierung",
|
||||||
@@ -717,7 +718,8 @@
|
|||||||
"opened_suffix": "— Zu lange geöffnet!",
|
"opened_suffix": "— Zu lange geöffnet!",
|
||||||
"opened_suffix_ok": "— Geöffnet (noch ok)",
|
"opened_suffix_ok": "— Geöffnet (noch ok)",
|
||||||
"opened_suffix_warning": "— Geöffnet (erst prüfen)",
|
"opened_suffix_warning": "— Geöffnet (erst prüfen)",
|
||||||
"days_compact": "{n}T"
|
"days_compact": "{n}T",
|
||||||
|
"badge_check_soon": "Bald prüfen"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
|
|||||||
@@ -341,6 +341,7 @@
|
|||||||
"regenerate": "🔄 Generate another one",
|
"regenerate": "🔄 Generate another one",
|
||||||
"close_btn": "✅ Close",
|
"close_btn": "✅ Close",
|
||||||
"ingredients_title": "🧾 Ingredients",
|
"ingredients_title": "🧾 Ingredients",
|
||||||
|
"tools_title": "Equipment needed",
|
||||||
"steps_title": "👨🍳 Steps",
|
"steps_title": "👨🍳 Steps",
|
||||||
"no_steps": "No steps available",
|
"no_steps": "No steps available",
|
||||||
"generate_error": "Generation error",
|
"generate_error": "Generation error",
|
||||||
@@ -717,7 +718,8 @@
|
|||||||
"opened_suffix": "— Opened too long!",
|
"opened_suffix": "— Opened too long!",
|
||||||
"opened_suffix_ok": "— Opened (still ok)",
|
"opened_suffix_ok": "— Opened (still ok)",
|
||||||
"opened_suffix_warning": "— Opened (check first)",
|
"opened_suffix_warning": "— Opened (check first)",
|
||||||
"days_compact": "{n}d"
|
"days_compact": "{n}d",
|
||||||
|
"badge_check_soon": "Check soon"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
|
|||||||
@@ -341,6 +341,7 @@
|
|||||||
"regenerate": "🔄 Generane un'altra",
|
"regenerate": "🔄 Generane un'altra",
|
||||||
"close_btn": "✅ Chiudi",
|
"close_btn": "✅ Chiudi",
|
||||||
"ingredients_title": "🧾 Ingredienti",
|
"ingredients_title": "🧾 Ingredienti",
|
||||||
|
"tools_title": "Strumenti necessari",
|
||||||
"steps_title": "👨🍳 Procedimento",
|
"steps_title": "👨🍳 Procedimento",
|
||||||
"no_steps": "Nessun procedimento disponibile",
|
"no_steps": "Nessun procedimento disponibile",
|
||||||
"generate_error": "Errore nella generazione",
|
"generate_error": "Errore nella generazione",
|
||||||
@@ -717,7 +718,8 @@
|
|||||||
"opened_suffix": "— Aperto da troppo tempo!",
|
"opened_suffix": "— Aperto da troppo tempo!",
|
||||||
"opened_suffix_ok": "— Aperto (ancora ok)",
|
"opened_suffix_ok": "— Aperto (ancora ok)",
|
||||||
"opened_suffix_warning": "— Aperto (controlla)",
|
"opened_suffix_warning": "— Aperto (controlla)",
|
||||||
"days_compact": "{n}gg"
|
"days_compact": "{n}gg",
|
||||||
|
"badge_check_soon": "Controlla presto"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
|
|||||||