Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cb4ae13f1 |
@@ -1 +1 @@
|
|||||||
ko_fi: evershelfproject
|
ko_fi: dadaloop82
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
|
|
||||||
- name: Set up Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@v3
|
uses: gradle/actions/setup-gradle@v6
|
||||||
with:
|
with:
|
||||||
gradle-version: '8.6'
|
gradle-version: '8.6'
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
|
|
||||||
- name: Set up Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@v3
|
uses: gradle/actions/setup-gradle@v6
|
||||||
with:
|
with:
|
||||||
gradle-version: '8.4'
|
gradle-version: '8.4'
|
||||||
|
|
||||||
|
|||||||
@@ -119,63 +119,3 @@ 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,7 +7,6 @@ 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'
|
||||||
@@ -28,17 +27,16 @@ 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@v0.36.0
|
uses: aquasecurity/trivy-action@master
|
||||||
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@v4
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-results.sarif'
|
sarif_file: 'trivy-results.sarif'
|
||||||
category: 'trivy-docker'
|
category: 'trivy-docker'
|
||||||
@@ -54,18 +52,17 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Run Trivy filesystem scanner
|
- name: Run Trivy filesystem scanner
|
||||||
uses: aquasecurity/trivy-action@v0.36.0
|
uses: aquasecurity/trivy-action@master
|
||||||
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@v4
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-fs-results.sarif'
|
sarif_file: 'trivy-fs-results.sarif'
|
||||||
category: 'trivy-fs'
|
category: 'trivy-fs'
|
||||||
|
|||||||
@@ -49,4 +49,3 @@ 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-bookworm
|
FROM php:8.2-apache
|
||||||
|
|
||||||
# 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 upgrade -y && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libsqlite3-dev \
|
libsqlite3-dev \
|
||||||
libcurl4-openssl-dev \
|
libcurl4-openssl-dev \
|
||||||
libonig-dev \
|
libonig-dev \
|
||||||
|
|||||||
@@ -32,8 +32,6 @@
|
|||||||
[](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
|
||||||
@@ -104,7 +102,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.
|
- **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.
|
||||||
|
|
||||||
### 📺 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
|
||||||
@@ -352,35 +350,10 @@ The application uses no build tools — edit files directly and refresh.
|
|||||||
|
|
||||||
## 📋 Roadmap
|
## 📋 Roadmap
|
||||||
|
|
||||||
### High Priority
|
- [ ] User authentication / multi-user support
|
||||||
- [ ] **Cooking mode — 3D wheel JS** — swipe navigation, gyroscope tilt, haptic feedback
|
- [ ] Offline mode with service worker
|
||||||
- [ ] **Cooking mode — step timers** — auto-detect "X minutes" in recipe steps, countdown + alert
|
- [ ] Export/import inventory data
|
||||||
- [ ] **Push notifications** — daily expiry alerts via PWA Service Worker + VAPID
|
- [ ] Notification system (Telegram, email) for expiring products
|
||||||
- [ ] **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,14 +138,11 @@ 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
|
||||||
)
|
)
|
||||||
");
|
");
|
||||||
// Insert with explicit columns: transactions_old may lack 'undone' (pre-v1.7.x DB)
|
$db->exec("INSERT INTO transactions SELECT * FROM transactions_old");
|
||||||
$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)");
|
||||||
@@ -363,10 +360,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,7 +409,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 180;
|
if (preg_match('/confettura|marmellata/', $n)) return 60;
|
||||||
if (preg_match('/nutella|cioccolat/', $n)) return 60;
|
if (preg_match('/nutella|cioccolat/', $n)) return 60;
|
||||||
|
|
||||||
// ── H: Category fallbacks ────────────────────────────────────────────
|
// ── H: Category fallbacks ────────────────────────────────────────────
|
||||||
@@ -474,7 +467,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 = 30; // whole tubers in a bag, pantry: 3-5 weeks
|
elseif (preg_match('/patata|patate/', $n)) $days = 14;
|
||||||
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,7 +3020,6 @@ 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...',
|
||||||
@@ -3043,7 +3042,6 @@ 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...',
|
||||||
@@ -3066,7 +3064,6 @@ 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;
|
||||||
@@ -3444,15 +3441,14 @@ 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`, `tools_needed`). Keep `meal` unchanged.
|
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). 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":"…","tools_needed":["…"],"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":"…","ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"}
|
||||||
PROMPT;
|
PROMPT;
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
@@ -4321,15 +4317,14 @@ 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`, `tools_needed`). Keep `meal` unchanged.
|
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`). 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":"…","tools_needed":["…"],"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":"…","ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"…"}
|
||||||
PROMPT;
|
PROMPT;
|
||||||
|
|
||||||
$genConfig = [
|
$genConfig = [
|
||||||
|
|||||||
@@ -3918,29 +3918,6 @@ 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;
|
||||||
@@ -4317,153 +4294,14 @@ body.cooking-mode-active .app-header {
|
|||||||
transform: scale(1.35);
|
transform: scale(1.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cooking-wheel {
|
|
||||||
position: relative;
|
|
||||||
--wheel-tilt-x: 0deg;
|
|
||||||
--wheel-tilt-y: 0deg;
|
|
||||||
--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;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding: 18px 20px;
|
|
||||||
border-radius: 20px;
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
transition: transform 0.22s ease, opacity 0.22s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cooking-step-text {
|
.cooking-step-text {
|
||||||
position: relative;
|
font-size: clamp(1.4rem, 5vw, 2.2rem);
|
||||||
z-index: 3;
|
line-height: 1.5;
|
||||||
font-size: clamp(1.35rem, 4.6vw, 2.1rem);
|
font-weight: 500;
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0.06));
|
text-align: center;
|
||||||
border: 1px solid rgba(255,255,255,0.20);
|
max-width: 560px;
|
||||||
box-shadow: 0 18px 35px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.24);
|
width: 100%;
|
||||||
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 {
|
||||||
@@ -4503,28 +4341,6 @@ 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;
|
||||||
@@ -4760,32 +4576,6 @@ 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 = 30; // whole tubers in a bag, pantry: 3-5 weeks
|
else if (/patata|patate/.test(name)) days = 14;
|
||||||
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, 30);
|
else if (/patata|patate/.test(name)) days = Math.max(days, 21);
|
||||||
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 180;
|
if (/confettura|marmellata/.test(name)) return 60;
|
||||||
if (/nutella|cioccolat/.test(name)) return 60;
|
if (/nutella|cioccolat/.test(name)) return 60;
|
||||||
|
|
||||||
// ── H: Category fallbacks ────────────────────────────────────────────
|
// ── H: Category fallbacks ────────────────────────────────────────────
|
||||||
@@ -2499,24 +2499,9 @@ 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) {
|
||||||
@@ -3619,16 +3604,8 @@ async function loadDashboard() {
|
|||||||
if (days !== null && days !== undefined) {
|
if (days !== null && days !== undefined) {
|
||||||
let expiryClass, expiryText;
|
let expiryClass, expiryText;
|
||||||
if (!isEdible) {
|
if (!isEdible) {
|
||||||
// Only show the red ⛔ badge for items that are genuinely dangerous.
|
expiryClass = 'opened-expiry-spoiled';
|
||||||
// For conserve/condiments classified as safe, use a gentler amber badge.
|
expiryText = t('expiry.badge_expired');
|
||||||
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');
|
||||||
@@ -4910,8 +4887,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();
|
||||||
// Native kiosk settings button is permanently replaced by the web overlay button — keep hidden.
|
// Restore native kiosk settings button visibility
|
||||||
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(false); } catch (_) {}
|
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(true); } catch (_) {}
|
||||||
_cancelScaleAutoConfirm(false);
|
_cancelScaleAutoConfirm(false);
|
||||||
_scaleRecipeAutoFillPaused = false;
|
_scaleRecipeAutoFillPaused = false;
|
||||||
_scaleUserDismissed = false;
|
_scaleUserDismissed = false;
|
||||||
@@ -7626,9 +7603,6 @@ 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);
|
||||||
@@ -7827,42 +7801,6 @@ 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) || '{}');
|
||||||
@@ -8215,22 +8153,6 @@ 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>`
|
||||||
@@ -8264,11 +8186,6 @@ 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;
|
||||||
@@ -8280,14 +8197,9 @@ async function _saveVacuumAndStay(openedId) {
|
|||||||
showPage('dashboard');
|
showPage('dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId, forcedVacuum) {
|
async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId) {
|
||||||
clearMoveModalTimer();
|
clearMoveModalTimer();
|
||||||
const newVacuum = forcedVacuum !== undefined ? (forcedVacuum ? 1 : 0) : (document.getElementById('move-vacuum-check')?.checked ? 1 : 0);
|
const newVacuum = 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 {
|
||||||
@@ -11937,36 +11849,6 @@ 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>`;
|
||||||
|
|
||||||
@@ -11984,14 +11866,6 @@ 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) => {
|
||||||
@@ -12127,25 +12001,8 @@ 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);
|
||||||
@@ -12159,9 +12016,6 @@ 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 */ }
|
||||||
@@ -12494,10 +12348,7 @@ function _initBrowserTtsVoices(selectedVoice) {
|
|||||||
sel.innerHTML = '<option value="">— Caricamento voci… —</option>';
|
sel.innerHTML = '<option value="">— Caricamento voci… —</option>';
|
||||||
|
|
||||||
const populate = () => {
|
const populate = () => {
|
||||||
let voices = [];
|
const voices = window.speechSynthesis.getVoices();
|
||||||
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 **"Grant permissions"** to **"✅ Permissions granted — Continue →"** (green) once all permissions are granted.
|
The button transforms from "Concedi permessi" to **"✅ Permessi concessi — Continua →"** (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 "Auto-discover"** to let the wizard scan your LAN:
|
**Or tap "Rileva automaticamente"** 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,23 +62,9 @@ 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 overlay (top-left). A confirmation dialog appears.
|
Tap the **✕** button in the header (top-left). A confirmation dialog appears.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -147,6 +133,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` | Over-the-air kiosk self-updates (installs new APK from GitHub releases) |
|
| `REQUEST_INSTALL_PACKAGES` | Install the Scale Gateway APK |
|
||||||
| `ACCESS_WIFI_STATE` | LAN auto-discovery |
|
| `ACCESS_WIFI_STATE` | LAN auto-discovery |
|
||||||
| `REORDER_TASKS` | Bring the kiosk app to foreground when needed |
|
| `REORDER_TASKS` | Bring app to foreground after gateway launch |
|
||||||
|
|||||||
@@ -43,13 +43,15 @@ docker compose up -d
|
|||||||
|
|
||||||
## AI Features
|
## AI Features
|
||||||
|
|
||||||
### "AI not available" error
|
### AI features don't work / "AI non disponibile"
|
||||||
|
|
||||||
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
|
||||||
@@ -60,7 +62,7 @@ max_execution_time = 120
|
|||||||
|
|
||||||
## Shopping List (Bring!)
|
## Shopping List (Bring!)
|
||||||
|
|
||||||
### "Bring! not configured" message in the shopping tab
|
### "Bring! non configurato" message in the shopping tab
|
||||||
|
|
||||||
Add your Bring! credentials to `.env`:
|
Add your Bring! credentials to `.env`:
|
||||||
|
|
||||||
@@ -88,7 +90,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 **"⚖️ Read Scale"** first to activate the scale modal
|
- Make sure you tapped "⚖️ Leggi bilancia" 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
|
||||||
@@ -107,19 +109,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)
|
||||||
|
|
||||||
### Kiosk app update fails
|
### Gateway install fails with an error dialog
|
||||||
|
|
||||||
The kiosk checks for a new release every 6 hours and downloads it from GitHub. If the install fails:
|
The dialog shows the exact failure code. Common causes:
|
||||||
|
|
||||||
| Symptom | Fix |
|
| Code | Cause | Fix |
|
||||||
|---------|-----|
|
|------|-------|-----|
|
||||||
| "Install from unknown sources" dialog | Enable the setting for the EverShelf Kiosk app in Android Settings |
|
| `STATUS_FAILURE` (1) | Generic install failure — often OEM restriction | Enable "Install from unknown sources" for the kiosk app in Android Settings |
|
||||||
| Persistent failure after download | Force-stop the app, clear its data, and relaunch the update flow |
|
| `STATUS_FAILURE_CONFLICT` (3) | Signature mismatch with existing install | Uninstall the old gateway app, then retry |
|
||||||
| Not enough space | Free up storage on the device |
|
| `STATUS_FAILURE_STORAGE` (6) | Not enough storage | Free up space on the device |
|
||||||
|
|
||||||
### Exit button (✕) is not visible
|
### Exit button (✕) is not visible
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### App is stuck in kiosk mode after a crash
|
### App is stuck in kiosk mode after a crash
|
||||||
|
|
||||||
@@ -137,7 +139,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 **"Load more"** to load more. Entries older than the database creation date won't appear.
|
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.
|
||||||
|
|
||||||
### "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:
|
||||||
- "What can I make with eggs and pasta?"
|
- "Cosa posso fare con le uova e la pasta?"
|
||||||
- "How long does cooked ham last once opened in the fridge?"
|
- "Quanti giorni dura il prosciutto cotto aperto in frigo?"
|
||||||
- "Suggest a quick snack"
|
- "Suggeriscimi uno spuntino veloce"
|
||||||
|
|
||||||
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 **▶ Start Cooking**.
|
Start cooking mode from any recipe by tapping **▶ Avvia cottura**.
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ Start cooking mode from any recipe by tapping **▶ Start Cooking**.
|
|||||||
- 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!" *(Enjoy your meal!)* spoken on the last step
|
- **Recipe completion** — "Buon appetito!" 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
|
||||||
- "🤖 Explain" for AI explanation
|
- "🤖 Spiega" for AI explanation
|
||||||
- Dismiss (with current quantity shown: "The quantity is correct (2 pcs)")
|
- Dismiss (with current quantity shown: "La quantità è giusta (2 pz)")
|
||||||
|
|
||||||
### Anti-Waste Report
|
### Anti-Waste Report
|
||||||
|
|
||||||
|
|||||||
@@ -47,11 +47,8 @@ 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)
|
||||||
- **Fix:** Kiosk Settings button (⚙️) added to the web overlay — tapping the camera button no longer accidentally opens kiosk settings
|
- **Critical fix:** Fresh-install crash resolved — `transactions` schema was missing the `undone` column, causing a database failure on every new installation
|
||||||
- **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"
|
- **Fix:** Race condition in DB migrations no longer causes `duplicate column name` errors on concurrent first requests
|
||||||
- **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
|
||||||
@@ -84,7 +81,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) — DEPRECATED, built into kiosk since v1.6.0
|
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ In EverShelf **Settings → Scale**:
|
|||||||
|
|
||||||
### 4. Connect your scale
|
### 4. Connect your scale
|
||||||
|
|
||||||
Tap **"Find Bluetooth Scales"**. Make sure your scale is powered on. Tap it in the list to pair and connect.
|
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is powered on. Tap it in the list to pair and connect.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ Tap **"Find Bluetooth Scales"**. Make sure your scale is powered on. Tap it in t
|
|||||||
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 **"⚖️ Read Scale"** button appears
|
2. A **"⚖️ Leggi bilancia"** 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 "Disconnect / Reconnect" in the gateway app to refresh the WebSocket connection
|
- Tap "Disconnetti / Riconnetti" 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 = 14
|
versionCode = 13
|
||||||
versionName = "1.7.13"
|
versionName = "1.7.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
# Build trigger: versionName 1.7.13 fix (8d87494)
|
# Build trigger: TTS bridge fix (95389eb)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.class
|
||||||
|
*.dex
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# ~~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)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 122 B |
|
After Width: | Height: | Size: 122 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 546 B |
|
After Width: | Height: | Size: 546 B |
@@ -0,0 +1,12 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">EverShelf Scale Gateway</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<!-- App-private external dir: no storage permission needed -->
|
||||||
|
<external-files-path name="apk_downloads" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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.13</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.12</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" id="use-location-group">
|
<div class="form-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,7 +1522,6 @@
|
|||||||
<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,7 +341,6 @@
|
|||||||
"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",
|
||||||
@@ -718,8 +717,7 @@
|
|||||||
"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,7 +341,6 @@
|
|||||||
"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",
|
||||||
@@ -718,8 +717,7 @@
|
|||||||
"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,7 +341,6 @@
|
|||||||
"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",
|
||||||
@@ -718,8 +717,7 @@
|
|||||||
"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",
|
||||||
|
|||||||