Compare commits
440 Commits
latest
...
kiosk-1.7.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 49e5319f4c | |||
| 3ebe551b9e | |||
| 0e1eccfe33 | |||
| 4624811707 | |||
| 3607ebf1d7 | |||
| 8bb6c01b7d | |||
| b1a882f92d | |||
| 1b7b271b43 | |||
| 8d22b36b0f | |||
| 2d70e7a688 | |||
| 8da627f8a6 | |||
| d3492a38f7 | |||
| 34e13075ea | |||
| bba0f4715a | |||
| e3ae2d10b2 | |||
| 696a9c6d11 | |||
| 35de42657a | |||
| 27ba41700f | |||
| 2f040ee041 | |||
| 2c34387592 | |||
| 3e65c7ee43 | |||
| 49ac0b4ce8 | |||
| 9e2722f7a4 | |||
| c47eba3ddb | |||
| d056a6a116 | |||
| cdc776d7c7 | |||
| cb39b63997 | |||
| 1eb7a6733c | |||
| 5b401f8d5f | |||
| 448a8237f6 | |||
| 20c16401d2 | |||
| 5ffc4581f4 | |||
| a9a512e014 | |||
| 7cc4ce91a9 | |||
| 3391106010 | |||
| 184042a0ae | |||
| 85090ecc9f | |||
| 27238a39cb | |||
| 6cb7aeaf5b | |||
| 8407dea781 | |||
| e3975b7d2e | |||
| b5789bbc8b | |||
| 38c6c5aac3 | |||
| fc2849be19 | |||
| a21b54deaa | |||
| 0fd39db5d3 | |||
| da62647089 | |||
| b65e381329 | |||
| 763b7fd057 | |||
| 8bff68dd33 | |||
| d1139a7e4b | |||
| 5fccb5309c | |||
| a99e2cb80b | |||
| 63ede4fb53 | |||
| 5028fb0e72 | |||
| 370a5a62b0 | |||
| 75eb77c9b1 | |||
| ac7368e49d | |||
| 2db01b8cd3 | |||
| 2f04543de3 | |||
| 61725b17da | |||
| c831387f70 | |||
| 06cba1ea71 | |||
| 073b4b9cfa | |||
| b1a3fbed5a | |||
| 9973edf463 | |||
| b23921fc83 | |||
| 5462879783 | |||
| 97538822ef | |||
| 7de556e25c | |||
| 6929a226b9 | |||
| 93684c5842 | |||
| 7913bf4de4 | |||
| 75ca49ac4e | |||
| 3a9051e8c8 | |||
| ed447d5811 | |||
| 6284116525 | |||
| f65fb4365c | |||
| 1102579ea8 | |||
| 10114dae50 | |||
| 9ab49c1d41 | |||
| 96d3514c38 | |||
| 584fcf5fed | |||
| 336d9091be | |||
| fa9bafef8f | |||
| 3e6e8dc0c7 | |||
| 5201fb7c58 | |||
| be8dfe9e1e | |||
| 07e9db3442 | |||
| 7cb1dfe285 | |||
| ef9a8c5c2f | |||
| f4dbd151a8 | |||
| 705e1f2d95 | |||
| 4f98a63414 | |||
| d766786e28 | |||
| d2e5eea05b | |||
| f1237ed271 | |||
| b412cc0ebe | |||
| c28195b250 | |||
| 7a51a44b86 | |||
| bc36c93c08 | |||
| c02a2fc632 | |||
| 7fd2da1d67 | |||
| dfbdbc6efb | |||
| 12560dd4c1 | |||
| 6c342a412b | |||
| 12934a045c | |||
| a01ca583ea | |||
| 47fe51148f | |||
| acd6ddb140 | |||
| 13b55104a1 | |||
| 5d9eadf691 | |||
| ffa3daba0e | |||
| bcd7580729 | |||
| 6c11c0105a | |||
| 1584d402e4 | |||
| 20192f902d | |||
| e06d2d506a | |||
| b9082eae52 | |||
| 60da4e6989 | |||
| 0de9a62058 | |||
| 3aabaeccb7 | |||
| 3a9f0ccf79 | |||
| dfe14c1f34 | |||
| 5f510c0451 | |||
| 7005708e95 | |||
| 4196130835 | |||
| eeb2b512ad | |||
| ffb0341eb6 | |||
| 459766fa80 | |||
| 725e2ee5ee | |||
| 709a8c769b | |||
| 8535f4d4b9 | |||
| 6c9478f09d | |||
| cbb3f6b288 | |||
| d793682362 | |||
| 484b378be9 | |||
| f93c5bf111 | |||
| 8d0ffef600 | |||
| 3ad1cd6a52 | |||
| 1c890c66ea | |||
| ed2b792722 | |||
| 891733aa8c | |||
| f3d9bc903c | |||
| e002cc4483 | |||
| 2662e10b29 | |||
| 115c966322 | |||
| 22bbf46d95 | |||
| f04e227cc0 | |||
| 1013118db3 | |||
| 6d098a80a6 | |||
| 8322e535b0 | |||
| 8eba5c8573 | |||
| 2a43b9ccfb | |||
| 6f19d1bcd5 | |||
| 3c0f02b1c0 | |||
| ed89e74b94 | |||
| 9ea85b238c | |||
| f48fb02589 | |||
| 865de419e0 | |||
| af9bae3093 | |||
| c4498fa65d | |||
| 521d8f8e47 | |||
| edd1b2db42 | |||
| 14a7bbccbe | |||
| cdfffc3b22 | |||
| c7b04f410b | |||
| 1b6c641629 | |||
| 754f13111f | |||
| e5e010b5a4 | |||
| 7ea5505a0d | |||
| 0a3ea44b82 | |||
| a5094920bf | |||
| 2c56fb85c1 | |||
| 9cb29de1f0 | |||
| afc850557a | |||
| 8ee6fe8770 | |||
| bc9484d69e | |||
| 30aa2db0b9 | |||
| 23c26fc9f4 | |||
| b8d91c0089 | |||
| 642a9ae973 | |||
| 2ebf79d394 | |||
| 25d2a60b3a | |||
| 6500d22242 | |||
| 8932f270b0 | |||
| 84d2ff0264 | |||
| 9edd3eae2a | |||
| b2a334340b | |||
| 2c786c6794 | |||
| 0f0ce684f1 | |||
| 7ffd7d73e8 | |||
| abeb87c536 | |||
| d6ecbae30a | |||
| fc47cd8c27 | |||
| b0ef121f19 | |||
| 1e8c299052 | |||
| f3b0bdaf2d | |||
| 36d2328eb4 | |||
| e6dfa0389e | |||
| d02e48543f | |||
| e6ddc4b0dd | |||
| 74c4f8bd78 | |||
| 7b45389357 | |||
| 2e46090adc | |||
| 742f5834ae | |||
| e4869c4308 | |||
| 24da70da8c | |||
| 6d13b895ea | |||
| 61d1654590 | |||
| 4f6592b749 | |||
| eadcfe4c57 | |||
| 3fd2d915fa | |||
| 80ab97a5ec | |||
| 5af62e61cd | |||
| 120b1032b4 | |||
| d1040e5747 | |||
| 60f34c4389 | |||
| cfcba32c45 | |||
| 5ddf19f9e9 | |||
| 09fd122718 | |||
| 79d68ca274 | |||
| bff22d43b4 | |||
| 74abb73912 | |||
| 874ae90afa | |||
| e53d6f78b5 | |||
| 4df06b01f4 | |||
| c14f1927af | |||
| 8d56d4a221 | |||
| 52d4a0e23d | |||
| ab647f38d6 | |||
| a6e8136ba0 | |||
| 9b83acdef6 | |||
| d9602df3c3 | |||
| 1fb00d48a9 | |||
| 922e633ec9 | |||
| 108f3ef283 | |||
| d4d1aca774 | |||
| bc0beea090 | |||
| 56584e07df | |||
| 04cba79519 | |||
| 5cb1799d1d | |||
| e68d11a7fc | |||
| 74a60f5bbf | |||
| 3649be848a | |||
| 644fa2b94f | |||
| fa9c52e997 | |||
| b06beb23be | |||
| f625e55526 | |||
| 04efbe29b3 | |||
| 77c2bd59a7 | |||
| 13e88bc5b8 | |||
| bf27469228 | |||
| 60c1f406cc | |||
| 529c09fda3 | |||
| 1b7fe58769 | |||
| a85390b498 | |||
| 9e078c9930 | |||
| d635635577 | |||
| bb77395a3a | |||
| 968e26cc6a | |||
| 84229a4345 | |||
| 6756b16ecb | |||
| e12a1ebde1 | |||
| c6e3d13e8c | |||
| 03bd0bb321 | |||
| 7c61ae61bb | |||
| 653746e913 | |||
| 22e506bd66 | |||
| 7d134f561a | |||
| 38eb66cfbf | |||
| a2b1a6f2cf | |||
| 15e1dfbd69 | |||
| f26b9c291d | |||
| 645162f063 | |||
| 7cb39d94b6 | |||
| 0dac10d05e | |||
| 8865131557 | |||
| fe633c97cb | |||
| fe7aca43ab | |||
| 7d8132a743 | |||
| 4b27f249e3 | |||
| 03f201c651 | |||
| 0dbe5cf00f | |||
| 4897da571d | |||
| a701e5a239 | |||
| eb2e8fa1d0 | |||
| e3df15bf9e | |||
| 9e4a8323c3 | |||
| 73fbb73974 | |||
| 58e69625bd | |||
| f9718fee6d | |||
| 9ef2a53aeb | |||
| 076cf13ed8 | |||
| ea40c8e02b | |||
| f2e151d89b | |||
| a6c2fb93cf | |||
| c814d99d1f | |||
| 4e583127dd | |||
| 8359b14931 | |||
| 42c0896e7b | |||
| 9249a2f936 | |||
| e4d71f6409 | |||
| 2ec9b5d6c0 | |||
| 2980a150e4 | |||
| 8d02e76501 | |||
| c4f963dbd8 | |||
| e71ef3aba3 | |||
| abf42059ad | |||
| 3c9fe7dfea | |||
| 0cf64ccca1 | |||
| 9c1346019c | |||
| 8938ae517f | |||
| 67f58172e5 | |||
| 6372db6cb6 | |||
| 85274948b4 | |||
| c98f5d47bb | |||
| da46fec174 | |||
| bcf0a8927d | |||
| 22266cb620 | |||
| 60e6f3c09c | |||
| e002955173 | |||
| cd76f5bcdd | |||
| 7c4dd99289 | |||
| f1129b97f2 | |||
| 0f247a3132 | |||
| 3590ac8a77 | |||
| 0163ae11a2 | |||
| e912aca219 | |||
| ee2c280167 | |||
| 0d0b52b048 | |||
| 2c06be33d4 | |||
| 5ec5dc8e4b | |||
| 8558db1925 | |||
| 8722f15aa0 | |||
| dc25c2fa52 | |||
| 105c3298f3 | |||
| c3b19a6c48 | |||
| 8a16307b39 | |||
| 1606cb3a90 | |||
| 608afb086d | |||
| d1478245da | |||
| cb75558581 | |||
| 8258591e44 | |||
| 28a8c938bd | |||
| d269f919b9 | |||
| 679b3f16a8 | |||
| 97f6681e24 | |||
| a5a6e80b31 | |||
| fd5ff00d82 | |||
| 1a73ed91dd | |||
| 95389ebe87 | |||
| 8b5985dc80 | |||
| 430f9e7854 | |||
| 61e94db0d3 | |||
| 76c4344720 | |||
| 61e7d7d4bf | |||
| 36f6fcd232 | |||
| 5df0be1661 | |||
| 37299e60c9 | |||
| f57ad4b330 | |||
| fe0221e6d4 | |||
| 4a780f2743 | |||
| 546d4afd59 | |||
| db033844d4 | |||
| f4a62ef496 | |||
| 234cae14bc | |||
| 03142e2f7f | |||
| ce8133ad3f | |||
| 56c269d616 | |||
| 3d8dc66ec1 | |||
| 5bbedc8a3b | |||
| ae7d6772f6 | |||
| cd4fd55006 | |||
| 03a63d34fc | |||
| 4e4c1867bf | |||
| ccc1b0cdcc | |||
| 517a615d11 | |||
| 43624fafe1 | |||
| 5e01c0656c | |||
| 9240e20360 | |||
| abd8ab1829 | |||
| d814601b30 | |||
| 076593c564 | |||
| 84fec6406a | |||
| 63b721cf09 | |||
| e574e4d58d | |||
| 4db8882dbd | |||
| 745e042375 | |||
| cedd97fd73 | |||
| 2db86ca541 | |||
| c115f83879 | |||
| ed8c6fbd07 | |||
| d778817fd8 | |||
| 33163b9235 | |||
| 1021f04735 | |||
| 3901113e76 | |||
| 2edd5a6ebd | |||
| 37cd8caf4f | |||
| 07bdfe6b87 | |||
| 7dba155183 | |||
| 9e2a24def4 | |||
| 52cfbba663 | |||
| 32e2833b27 | |||
| ccd59269d4 | |||
| d37b43003c | |||
| 9083e25f37 | |||
| bc70f330f8 | |||
| 4250a37f0d | |||
| c45b8ddbb9 | |||
| 45040f250c | |||
| e38a6cb7f6 | |||
| 5991e666ec | |||
| 9363bc147e | |||
| f8c8dfb990 | |||
| 95b6258ad8 | |||
| d931b471f0 | |||
| 383ef1113d | |||
| 1c792a4e4a | |||
| 3e25fcd5df | |||
| 3ff91b3018 | |||
| 1c686fa842 | |||
| 951ef1d64f | |||
| 7144ec7386 | |||
| d26229800c | |||
| 6f5bc15734 | |||
| 55c5b34381 | |||
| 099a6cc4e8 | |||
| a146ba124a | |||
| 7be02c7174 | |||
| 0a35e9e8b4 | |||
| 690d5ecd18 | |||
| b606e2b361 | |||
| d839a7e267 | |||
| d03a4853b5 | |||
| 71c49e2c82 | |||
| d30e9e0aaa | |||
| 695ea19d5c | |||
| 4d972b824e | |||
| 0830b1b168 |
@@ -7,7 +7,6 @@ data/cron.log
|
||||
data/smart_shopping_cache.json
|
||||
data/bring_token.json
|
||||
data/bring_catalog.json
|
||||
data/dupliclick_token.json
|
||||
data/client_debug.log
|
||||
data/*.crt
|
||||
data/*.pem
|
||||
|
||||
@@ -20,3 +20,6 @@ TTS_AUTH_TYPE=bearer
|
||||
TTS_CONTENT_TYPE=application/json
|
||||
TTS_PAYLOAD_KEY=message
|
||||
TTS_ENABLED=false
|
||||
|
||||
# GitHub Error Reporting: token is hardcoded in api/index.php (same for all clients).
|
||||
# No .env entry needed — update GH_ISSUE_TOKEN constant in api/index.php to rotate.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Build & Release Kiosk APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'evershelf-kiosk/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Kiosk APK
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
gradle-version: '8.6'
|
||||
|
||||
- name: Get version name
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep 'versionName' evershelf-kiosk/app/build.gradle.kts | grep -oP '"\K[^"]+')
|
||||
echo "name=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Kiosk version: $VERSION"
|
||||
|
||||
- name: Build debug APK
|
||||
run: gradle assembleDebug --no-daemon
|
||||
working-directory: evershelf-kiosk
|
||||
|
||||
- name: Rename APK
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
cp evershelf-kiosk/app/build/outputs/apk/debug/app-debug.apk artifacts/evershelf-kiosk.apk
|
||||
|
||||
# Publish with a semver-compatible tag so the in-app update check can
|
||||
# compare versions numerically (tag "kiosk-1.7.0" → norm → "1.7.0").
|
||||
# Also update the "kiosk-latest" tag so the hardcoded download URL still works.
|
||||
- name: Create versioned release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="kiosk-${{ steps.version.outputs.name }}"
|
||||
# Delete old release with same tag if it exists (e.g. re-run on same version)
|
||||
gh release delete "$TAG" --yes 2>/dev/null || true
|
||||
git push --delete origin "$TAG" 2>/dev/null || true
|
||||
gh release create "$TAG" \
|
||||
--title "EverShelf Kiosk v${{ steps.version.outputs.name }}" \
|
||||
--notes "Kiosk mode app. Scarica e installa su Android 7.0+. L'aggiornamento OTA è automatico." \
|
||||
--latest \
|
||||
artifacts/evershelf-kiosk.apk
|
||||
|
||||
- name: Update kiosk-latest tag (for hardcoded download URL)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release delete kiosk-latest --yes 2>/dev/null || true
|
||||
git push --delete origin kiosk-latest 2>/dev/null || true
|
||||
sleep 3
|
||||
gh release create kiosk-latest \
|
||||
--title "EverShelf Kiosk Latest" \
|
||||
--notes "Alias automatico → kiosk-${{ steps.version.outputs.name }}" \
|
||||
--prerelease \
|
||||
artifacts/evershelf-kiosk.apk
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
paths:
|
||||
- 'evershelf-scale-gateway/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -86,3 +86,36 @@ jobs:
|
||||
else:
|
||||
print(f'{f}: complete ✓')
|
||||
"
|
||||
|
||||
# ── Auto-merge develop → main ────────────────────────────────────────────
|
||||
# Runs automatically after ALL checks pass on develop.
|
||||
# You never need to merge manually again — just push to develop.
|
||||
auto-merge-to-main:
|
||||
name: Auto-merge develop → main
|
||||
needs: [lint-php, lint-js, docker-build, validate-translations]
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout (full history)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure git bot identity
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Merge develop → main
|
||||
run: |
|
||||
LAST=$(git log --oneline -1 origin/develop)
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git merge --no-ff origin/develop \
|
||||
-m "chore: auto-merge develop → main
|
||||
|
||||
Triggered by: $LAST"
|
||||
git push origin main
|
||||
|
||||
@@ -11,7 +11,11 @@ data/cron.log
|
||||
data/smart_shopping_cache.json
|
||||
data/bring_token.json
|
||||
data/bring_catalog.json
|
||||
data/dupliclick_token.json
|
||||
data/bring_migrate_ts.json
|
||||
data/shopping_price_cache.json
|
||||
data/anomaly_dismissed.json
|
||||
data/opened_shelf_cache.json
|
||||
data/shopping_name_cache.json
|
||||
data/client_debug.log
|
||||
data/rate_limits/
|
||||
|
||||
@@ -34,3 +38,14 @@ Thumbs.db
|
||||
|
||||
# Raw screenshots (working folder, not for distribution)
|
||||
/screenshots/
|
||||
|
||||
# Android kiosk build artifacts (generated by Gradle — not source)
|
||||
evershelf-kiosk/.gradle/
|
||||
evershelf-kiosk/app/build/
|
||||
evershelf-kiosk/build/
|
||||
evershelf-scale-gateway/app/build/
|
||||
evershelf-scale-gateway/build/
|
||||
evershelf-kiosk/local.properties
|
||||
data/error_reports.log
|
||||
data/latest_release_cache.json
|
||||
data/food_facts_cache.json
|
||||
|
||||
@@ -9,3 +9,10 @@ RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^api/(.*)$ api/index.php?action=$1&%{QUERY_STRING} [L,QSA]
|
||||
AddType application/x-x509-ca-cert .crt
|
||||
|
||||
# Prevent caching of JS/CSS so kiosk always gets fresh files
|
||||
<FilesMatch "\.(js|css)$">
|
||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</FilesMatch>
|
||||
|
||||
@@ -2,9 +2,272 @@
|
||||
|
||||
All notable changes to EverShelf will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.7.12] - 2026-05-13
|
||||
|
||||
### Fixed
|
||||
- **Banner "Usa prima" con data calcolata confusa** — `_renderUseExpiryHint` mostrava una data di scadenza *calcolata* (shelf life dopo apertura) anziché la data reale. Ora, se il prodotto ha `opened_at`, il banner mostra "Quella [nel frigo], aperta da X giorni — usala prima!" usando la nuova chiave `use.expiry_warning_opened`.
|
||||
- **"Usa TUTTO / Finito" nelle ricette cancellava la riga** — `submitRecipeUse(true)` inviava `use_all: true` all'API che eseguiva un `DELETE` diretto sulla riga di inventario senza conferma. La funzione ora calcola la quantità esatta dagli item disponibili (`_recipeUseContext.items`) e invia un normale `inventory_use` con quantità esplicita.
|
||||
- **Ricette: `qty_number` in grammi per prodotti `pz`** — Il prompt AI e la post-elaborazione PHP ora istruiscono Gemini a esprimere `qty_number` come pezzi interi per ingredienti con unità `pz` (Pan bauletto, fette biscottate, ecc.). La lista ingredienti nel prompt include `[usa PEZZI interi]` per ogni prodotto `pz`. Il fallback PHP per `pz` senza `default_quantity` non divide più per 100 (trattando grammi come pezzi), ma usa il `qty_number` restituito dall'AI se sembra un conteggio plausibile, altrimenti 1.
|
||||
|
||||
### Added
|
||||
- **Traduzione `use.expiry_warning_opened`** — Nuova chiave in `it.json`, `en.json`, `de.json` con placeholder `{loc}` (posizione) e `{when}` (giorni dall'apertura).
|
||||
|
||||
## [1.7.11] - 2026-05-12
|
||||
|
||||
### Added
|
||||
- **Scan page redesign** — La pagina di scansione è stata completamente ridisegnata per tablet e mobile:
|
||||
- **2× zoom fisso** — zoom hardware se disponibile, altrimenti CSS `scale(2)` automatico.
|
||||
- **Torcia** — bottone nel viewport con feedback toast e stato visivo.
|
||||
- **Flip fotocamera** — switch front/back con persistenza in settings.
|
||||
- **3 tab input** — Barcode / Nome / AI per un accesso rapido a ciascuna modalità.
|
||||
- **Prodotti recenti** — chip degli ultimi 6 prodotti scansionati (localStorage), con icona categoria.
|
||||
- **Live code overlay** — codice barcode rilevato parzialmente mostrato in sovrimpressione nel viewport.
|
||||
- **Confirm overlay** — checkmark + nome prodotto per 900ms al riconoscimento avvenuto.
|
||||
- **Angoli guida** — frame visivo per inquadrare il barcode.
|
||||
- **AI Number OCR** — dopo 4s senza scansione, compare il bottone "Leggi numeri con AI": Gemini analizza l'immagine e legge le cifre del barcode anche se non viene letto otticamente.
|
||||
- **PHP `gemini_number_ocr`** — Nuovo endpoint POST; accetta un'immagine JPEG base64, chiede a Gemini di individuare il codice EAN-13 / EAN-8 stampato sul prodotto, e restituisce le cifre o `not_found`.
|
||||
|
||||
### Fixed
|
||||
- **Falsi positivi anomalia consumo "Mozzarella 3 pezzi"** — Rimossa la direzione `untracked` (consumo maggiore degli acquisti registrati) che generava banner su ogni prodotto con acquisti non tracciati. Ora vengono segnalate solo le anomalie `phantom` e `missing`.
|
||||
- **Predizione "~0g/settimana"** — Il modello richiedeva ora min 5 transazioni (era 3) e un arco temporale di almeno 7 giorni; se il consumo predetto è < 15% della baseline viene saltato, eliminando i falsi positivi su prodotti con poche transazioni ravvicinate.
|
||||
- **Menu a tendina suggerimenti sul campo Nome (scan)** — Rimosso `list="common-products"` dal campo di input, il datalist non viene più aperto su tablet.
|
||||
|
||||
## [1.7.10] - 2026-05-11
|
||||
|
||||
### Fixed
|
||||
- **Banner "Imposta scadenza" non faceva nulla** — `editBannerNoExpiry()` chiamava `openEditInventoryModal()` che non esiste. Corretto in `editInventoryItem()` (la funzione corretta usata da tutti gli altri handler banner). Aggiunto anche il fetch preventivo di `inventory_list` perché `currentInventory` è vuoto sulla dashboard.
|
||||
- **"Prodotto non trovato" aprendo modal da banner** — `currentInventory` è sempre vuoto sulla dashboard; il fetch dell'inventario ora avviene prima di aprire la modal (stesso pattern di `editReviewItem` e `weighBannerItem`).
|
||||
- **Banner scaduto su latte UHT aperto** — Il testo mostrava "Scaduto!" invece di "Aperto da troppo tempo". Ora i prodotti con `opened_at` mostrano "Aperto da N giorni in [posizione]" sia nel titolo che nel dettaglio del banner.
|
||||
- **Shelf life latte generico 4 → 7 giorni** — Il latte senza qualificatori (es. "Latte") veniva trattato come fresco (4 giorni). Il latte fresco è già gestito esplicitamente (`latte fresco/intero/parzial/scremato` → 3gg); il generico ora vale 7 giorni (default UHT). Fix applicato sia in PHP (`database.php`) che in JS (`app.js`).
|
||||
- **`opened_at` stale sulle confezioni intere dopo split** — Quando un uso splitta la riga in "confezioni intere + frazione aperta", la riga delle intere non azzerava `opened_at`. Ora tutti e 3 i percorsi di split eseguono `opened_at = NULL` sulla riga sigillata.
|
||||
- **`inventory_update` non registrava transazioni** — La modal di modifica quantità aggiornava l'inventario senza creare transazioni. La differenza viene ora registrata automaticamente come `'in'` o `'out'` con nota `[Correzione manuale]`, evitando falsi positivi nel rilevatore di anomalie.
|
||||
- **False anomalie di consumo dopo la spesa** — La baseline della prediction usava solo la quantità del rifornimento (`restockQty`), ignorando le scorte preesistenti → `actual > expected` sistematicamente. Nuova baseline: `qty_attuale + consumato_da_ultimo_rifornimento`, che riflette correttamente la realtà indipendentemente dalle scorte pregresse.
|
||||
- **Banner "consumo anomalo" su quasi tutti i prodotti** — Due fix:
|
||||
1. `expected = 0` non genera più anomalia "more" (il modello pensa che dovresti aver finito, ma hai ricomprato).
|
||||
2. Soglia "more than expected" alzata al 400% (era 30%); "less than expected" rimane al 30%.
|
||||
- **Sezione scaduti mostra prodotti già buttati** — La query `expired` mancava di `AND i.quantity > 0`; i prodotti buttati (qty=0) con scadenza passata continuavano ad apparire. Corretta la query + pulizia righe orfane nel DB.
|
||||
- **Hardcoded `scade il` in banner** — Stringa italiana hardcodata nel dettaglio del banner scaduti rimossa.
|
||||
- **Docker: `SQLSTATE[HY000][14] unable to open database file`** — Aggiunta `_ensureDataDir()` in `database.php` che crea la directory se mancante e tenta `chmod(0775)` se non scrivibile.
|
||||
|
||||
### Added
|
||||
- **i18n completa** — Aggiunti ~25 chiavi di traduzione mancanti per UI kiosk, gemini, banner, scanner, shopping, appliances in tutti e 3 i file (`it.json`, `en.json`, `de.json`). Totale: 934 chiavi per lingua.
|
||||
|
||||
|
||||
### Added
|
||||
- **Category badge on inventory items** — Every product in the inventory now displays a macro-category badge (icon + label) next to the location badge. Badges showing `altro` are asynchronously refined via the new `guess_category` AI endpoint (Gemini + `data/category_ai_cache.json` cache) so the correct category appears automatically after the page loads.
|
||||
- **Category search** — The inventory search bar now matches items by category. Typing "biscotti" returns every cookie/biscuit regardless of brand or exact name; the match uses both the direct category key and the translated label.
|
||||
- **Brand map in `guessCategoryFromName`** — A fast-path brand table (Oreo, Ringo, Uno, Barilla, De Cecco, Galbani, Mutti, Lavazza, etc.) provides instant category resolution before any regex evaluation.
|
||||
- **PHP `guess_category` endpoint** — New server-side action that calls Gemini to classify a product name into a local category key, with file-based caching (`data/category_ai_cache.json`). Returns `altro` immediately when no Gemini API key is configured.
|
||||
|
||||
### Fixed
|
||||
- **Duplicate banner alerts** — `loadBannerAlerts()` was occasionally enqueuing the same item multiple times when called concurrently. Fixed with a `_bannerLoading` re-entrancy guard and a `_queuedItemIds` Set that prevents any item from being pushed more than once per refresh cycle.
|
||||
- **`mapToLocalCategory` with `en:dairies` / `en:dairies-and-eggs`** — The dairy regex was not matching OpenFoodFacts tags that use the `dairi` stem; extended to cover the full range of dairy tags.
|
||||
- **`mapToLocalCategory` always returning `altro`** — When the input category was already `altro`, the function exited the direct-match loop before attempting any fallback, losing all name-based guesses. The loop now skips the `altro` key for the early-return and falls back to `guessCategoryFromName(productName)` at the end.
|
||||
- **"Tonno all'olio" → condimenti** — `tonno\b` was matched after `olio\b` (condimenti) due to regex ordering. Moved the conserve block before the condimenti block so tuna products resolve correctly.
|
||||
|
||||
### Security
|
||||
- **AI function guards** — All Gemini-powered functions now check `_geminiAvailable` (JS) or the presence of `GEMINI_API_KEY` (PHP) before executing. Affected functions: `_refineCategoryBadgesAsync`, `fetchAllPrices`, `getShoppingPrice`. The PHP endpoint returns `{"success":false,"error":"no_api_key"}` instead of silently returning empty results, making the missing-key state explicit and diagnosable.
|
||||
|
||||
## [1.7.8] - 2026-05-10
|
||||
|
||||
### Added
|
||||
- **Trasferisci a Ricette dalla chat** — Quando la chat con Gemini Chef genera una ricetta, compare il bottone "📥 Trasferisci a Ricette". Premendolo, Gemini converte il testo in JSON strutturato completo (titolo, pasti, ingredienti, passi), il backend arricchisce ogni ingrediente con product_id e location via fuzzy-match (identico a generateRecipe), la ricetta viene salvata in archivio e si apre direttamente nella sezione Ricette con tutti i pulsanti "Usa" e la modalità cottura completa.
|
||||
- **Bottone "Apri la ricetta"** — Dopo un trasferimento riuscito, il bottone "📥 Trasferisci a Ricette" si trasforma direttamente in "📖 Apri la ricetta" (stesso elemento DOM), evitando problemi di sovrapposizione.
|
||||
- **Crea una ricetta per ingrediente** — Nel pannello azione di ogni alimento in inventario compare il bottone "👨🍳 Crea una ricetta con questo" (teal, larghezza piena). Premendolo, Gemini genera una ricetta italiana usando quell'alimento come protagonista (stesso pipeline di chatToRecipe: arricchimento fuzzy-match inventario, meal=null, 8192 token max).
|
||||
- **meal non auto-categorizzato** — Le ricette generate da chat o da ingrediente non vengono più auto-categorizzate (meal rimane null); il tag pasto nell'UI viene mostrato solo se valorizzato.
|
||||
|
||||
### Fixed
|
||||
- **Smart shopping: falso positivo "quasi finito"** — Se un prodotto in grammi/ml era quasi esaurito (es. Burro 30g = 12%) ma lo stesso prodotto era disponibile anche come confezione (Burro 1 conf = 99%), il sistema segnalava ugualmente "sta finendo". Ora verifica se la famiglia `shopping_name` ha scorte da altri prodotti: se sì, l'alert viene soppresso. (Esempio: 30g di Burro + 1 conf di Burro → nessun alert.)
|
||||
- **Traduzioni JSON corrotte** — La sezione `action` era duplicata nei file `de.json`, `en.json` e `it.json`, causando errori di parsing che bloccavano la CI/CD. Rimossa la sezione spuria.
|
||||
|
||||
## [1.7.7] - 2026-05-10
|
||||
|
||||
### Fixed
|
||||
- **Smart shopping family suppression** — La logica `recentlyExhausted` (prodotti terminati < 14gg) bypassava erroneamente anche la suppression per `shopping_name` family, causando falsi positivi: prodotti come Yaourt Vanille apparivano come urgenti anche con 2kg di Yogurt in stock, Salame Paesano con 1kg di Affettato in stock, Gran bauletto rustico con più pani in stock. Ora `recentlyExhausted` bypassa solo il check token-based (match lasco), mentre la family suppression per `shopping_name` si applica sempre.
|
||||
- **Shelf life pre-warming nel cron** — Il cron ora chiama `prewarmShelfLifeCache()` ogni 5 minuti, precaricando via Gemini AI la shelf life degli item aperti in inventario (max 5 item per ciclo) prima che l'utente li visualizzi. Questo elimina il delay percepibile al primo click su "Aperto il...".
|
||||
|
||||
## [1.7.6] - 2026-05-10
|
||||
|
||||
### Fixed
|
||||
- **`shopping_name` troncato (Piadina)** — Il prodotto "Piadine medie" aveva `shopping_name='Pi'` (troncato), non veniva aggruppato correttamente nella famiglia. Corretto in `Piadina`.
|
||||
- **Family merges DB** — Grana Padano ora sotto `Formaggio` (era `Grana` singleton), Prosciutto cotto ora sotto `Affettato`, Panna acida ora sotto `Panna`.
|
||||
- **`daily_rate` su periodo effettivo** — Il tasso di consumo giornaliero usava `first_in → now` come finestra, diluendo il rate con periodi in cui il prodotto era già esaurito (es. aglio esaurito a 34gg veniva calcolato su 60+). Ora usa `first_in → last_activity` (ultimo acquisto o ultimo uso), più preciso per le previsioni di riordino.
|
||||
- **Anomaly dismiss key stabile** — La chiave di dismiss usava `product_id + round(expected)` che cambiava ad ogni nuova transazione, causando la ricomparsa delle anomalie già chiuse. Ora usa `product_id + direction` (phantom/missing/untracked) — stabile finché la direzione non cambia.
|
||||
- **Smart shopping: prodotti esauriti < 14 giorni** — Prodotti terminati negli ultimi 14 giorni non vengono più soppressi dal check token-coverage o shopping_name-family: se li hai appena finiti, è probabile tu voglia ricomprarli indipendentemente dalla presenza di equivalenti in stock.
|
||||
- **Chat pruning** — `chatSave()` ora esegue `DELETE` dei messaggi oltre i 200 più recenti dopo ogni salvataggio, evitando crescita illimitata della tabella `chat_messages`.
|
||||
- **`getStats()` query consolidate** — Le 5 query separate (COUNT products, SUM inventory, COUNT locations, COUNT recent_in, COUNT recent_out) sono ora una sola query con subselect, riducendo i round-trip SQLite da 5 a 1.
|
||||
- **Bring! cleanup rate-limiting** — Aggiunto `usleep(300ms)` tra le rimozioni multiple per evitare di sovraccaricare l'API Bring! in burst.
|
||||
- **Indici compositi su `transactions`** — Aggiunti `idx_transactions_type_date(type, created_at)` (per `getStats`) e `idx_transactions_pid_type_undone(product_id, type, undone)` (per `smartShopping`), con migration automatica per DB esistenti.
|
||||
|
||||
### Security
|
||||
- **CSRF protection** — Le action di scrittura (inventory_add, bring_add, product_save, ecc.) richiedono ora `X-EverShelf-Request: 1` oppure `Content-Type: application/json`. Il frontend `api()` invia sempre il header su POST. Questo previene attacchi CSRF cross-site tramite form HTML.
|
||||
|
||||
## [1.7.5] - 2026-05-10
|
||||
|
||||
### Added
|
||||
- **Vacuum sealed prompt on item use** — After using a conf/weighted-unit item that still has remaining stock, a sliding popup asks "🔒 Messo sotto vuoto?" with Sì/No buttons and an 8-second auto-dismiss countdown bar. Default is Sì if the item was previously sealed, No otherwise. Works for all container units (conf, g, kg, ml, l) and any item previously marked as vacuum sealed.
|
||||
- **Multi-function appliance awareness in recipes** — When the user sets a multi-function appliance (Cookeo, Bimby, Thermomix, Monsieur Cuisine, Instant Pot, Multicooker, Robot da cucina) in Settings, all Gemini recipe prompts (chat, recipe generation, weekly meal plan) now explicitly instruct the AI to consolidate as many cooking steps as possible into that single machine. Each appliance's available functions (rosolare, tritare, vapore, cuocere a pressione, etc.) are listed and the AI is required to indicate the specific mode/program at each step.
|
||||
- **Server-side Bring! cleanup in cron** — `bringCleanupObsolete()` now runs every 5 minutes via cron without requiring any client page load. Items auto-added by the app (identified by `⚡`/`🟠`/`🛒` markers in their Bring! spec) are automatically removed when the smart shopping engine no longer flags them as needed. Works across all devices/clients.
|
||||
- **`shopping_name` in `inventory_list` API** — The `inventory_list` endpoint now returns the `shopping_name` field from the products table, enabling family-based stock matching in the client-side cleanup fallback.
|
||||
|
||||
### Fixed
|
||||
- **Bring! cleanup: false token match (Succo/Frutta)** — `bringCleanupObsolete` previously indexed smart items by product name tokens. "Pera Italiana **Succo** e polpa **frutta**" (shopping_name: "Pere") caused "Succo" and "Frutta" to be retained on Bring! indefinitely even when fully stocked. Now indexes **only** by `shopping_name` tokens.
|
||||
- **Bring! cleanup: expired items with fresh family stock (Verdure)** — When a product is expired but its `shopping_name` family has ≥50% fresh stock from other products (e.g. Minestrone tradizione scaduto 01/05 but 590g fresh Verdure in freezer/pantry), it is no longer flagged as `critical` and is removed from the shopping list.
|
||||
- **Bring! remove: catalog items not removed (Formaggio/Käse)** — `bringRemoveItem()` and `bringCleanupObsolete()` now try both the Italian display name and the Bring! internal German catalog key (e.g. `Käse` for `Formaggio`). Previously, catalog items with a German key were silently not removed.
|
||||
- **Barcode scanner: EAN auto-submit on manual input** — Typing or pasting a valid 8/13-digit EAN in the manual barcode field now auto-submits immediately without needing to press a button. Checksum validation gives a warning toast for invalid codes without blocking entry.
|
||||
- **Shopping list: `isExpiringSoon` false positives** — Products bought in bulk that expire naturally in 3 days (e.g. fresh produce) were flagged `medium` urgency on the shopping list despite having 100%+ stock. Now requires `pctLeft < 50%` before triggering.
|
||||
- **Shopping list: expired batch with fresh restock suppressed** — Products with an expired batch AND a recent fresh restock (≥50% fresh stock) are no longer flagged `critical` for shopping. The expired-batch UI banner on the dashboard handles the disposal prompt instead.
|
||||
- **Shopping list: cross-device cleanup** — Client-side `cleanupObsoleteBringItems()` now detects app-added items by their spec markers (`⚡`/`🟠`/`🛒`) instead of a per-device localStorage map, making cleanup work correctly on all clients including newly logged-in devices. Throttle reduced from 30 minutes to 3 minutes.
|
||||
- **API fetch caching disabled** — All `api()` calls in the frontend now set `cache: 'no-store'` to prevent stale data from browser cache.
|
||||
- **Shopping page multi-client sync** — Added 45-second polling on the shopping page so changes made on another device are reflected automatically.
|
||||
|
||||
|
||||
|
||||
### Added
|
||||
- **AI price estimation for shopping list** — Each item on the Bring! shopping list now shows an estimated retail price badge (per unit and total). Prices are fetched from Gemini AI and cached server-side for 3 months (`PRICE_UPDATE_MONTHS`). The running estimated total is displayed both in the shopping tab and as a green pill badge on the dashboard stat card.
|
||||
- **Dashboard price total badge** — The shopping stat card on the dashboard shows a green `ca. €X.XX` badge (top-right, same position as the old urgency badge). It updates in real-time as prices are calculated and persists across navigation via `sessionStorage`.
|
||||
- **Background price refresh** — Prices are fetched silently every 2 minutes even when not on the shopping tab, keeping the dashboard badge current without user interaction.
|
||||
- **Smart quantity estimation** — The price payload uses `smart_shopping` data (consumption patterns) to send the correct buy quantity per item; falls back to Bring! spec parsing, then to `qty=1, unit=conf` for manually-added items.
|
||||
|
||||
### Fixed
|
||||
- **`stat-price-total` not visible on dashboard** — The total was only computed when `shoppingItems` was populated (i.e. shopping tab had been visited). Now uses `sessionStorage._pricetotal` as fallback so the badge is visible immediately on any page.
|
||||
- **Price bar reloading on every tab switch** — `renderShoppingItems` now checks if ALL items are already cached with matching qty/unit; if so, it applies prices from cache instantly with no loading bar or API call.
|
||||
- **`stat-price-total` real-time update** — Dashboard stat now increments as each individual item is priced (not only after the entire fetch completes).
|
||||
- **Broken emoji in `log.title`** — Corrupted `\uFFFD` character in `it.json` and `de.json` replaced with `📒`.
|
||||
- **`PRICE_CACHE_PATH` undefined crash** — Server-side constant was used inside functions that were called before the define; moved define to the very top of `api/index.php` (line 19). Affected: all `get_shopping_price` and `get_all_shopping_prices` calls from 16:33–16:40 on 2026-05-07.
|
||||
|
||||
## [1.7.1] - 2026-05-04
|
||||
|
||||
### Fixed
|
||||
- **Destructive actions now require confirmation** — "Butta tutto" (`throwAll`) and "Finisci tutto" (`submitUseAll`) now display a confirmation modal before executing. The modal features a 5-second auto-confirm countdown bar (red) with an "Annulla" cancel button, matching the scale auto-confirm UX pattern already in use.
|
||||
- **History undo button visibility** — The ↩ undo button in the transaction log was using `color: var(--text-muted)` making it nearly invisible. It now uses a red tint background + border (`#f87171`) with larger font size (1rem) for easy tap targeting.
|
||||
- **History undo uses custom modal** — `undoTransactionEntry()` previously used the native browser `confirm()` dialog (broken in Android WebView kiosk mode). It now uses the same `_showDestructiveConfirm()` modal with countdown.
|
||||
|
||||
|
||||
|
||||
### Added
|
||||
- **Demo mode (JS frontend)** — Full client-side demo experience: Gemini is treated as available, Bring! write operations silently no-op, and a mock pantry + shopping list is shown; activated via `?demo=1` URL param or `.env` `DEMO_MODE=true`; a "DEMO" badge is injected in the header and Settings is hidden to prevent accidental writes
|
||||
- **Graceful Bring! no-key state** — When Bring! credentials are not configured the shopping tab shows a friendly localised message with a direct link to the Settings page instead of a raw API error
|
||||
- **Use-quantity guard** — Consuming more than the quantity stocked at the selected location is now blocked before the API call; the quantity input shakes (CSS `input-shake` animation) and a toast shows `use.error_exceeds_stock`
|
||||
- **Kiosk: smart auto-discovery rewrite** — `autoDiscover()` now uses `ExecutorCompletionService` + `NetworkInterface` (replaces deprecated `WifiManager`), 60 parallel threads, 600 ms TCP pre-check per host, real-time UI feedback every 120 ms, ports `[443, 80, 8080, 8443]`; VPN/cellular interfaces (tun, ppp, rmnet, pdp, ccmni, etc.) are filtered out and `wlan*`/`eth*` interfaces are prioritised
|
||||
- **Kiosk: permissions button transform** — After permissions are granted, the button changes to "✅ Permessi concessi — Continua →" (green background, dark text) and advances to step 3 on tap, replacing the separate "permissions granted" card
|
||||
- **Kiosk: gateway auto-pre-configuration** — On successful gateway install `finishSetup()` POSTs `scale_enabled=true` + `scale_gateway_url=ws://127.0.0.1:8765` to the server's `save_settings` endpoint so the webapp is scale-ready immediately after setup
|
||||
- **Kiosk: ErrorReporter init at setup start** — `SetupActivity.onCreate()` now calls `ErrorReporter.init()` with any previously saved URL, ensuring errors in step 4 (gateway install) are reported even before the user confirms the server URL
|
||||
|
||||
### Fixed
|
||||
- **Kiosk: wrong subnet scanned** — The previous implementation picked up VPN/tun interfaces and scanned a 10.x.x.x range instead of the device's actual Wi-Fi LAN; fixed by filtering interface names and preferring `wlan`/`eth`
|
||||
- **Kiosk: port 443 missing from discovery** — HTTPS servers were never reachable during auto-discovery; ports list extended to `[443, 80, 8080, 8443]`
|
||||
- **Kiosk: gateway install status=1 silent failure** — `PackageInstaller.STATUS_FAILURE` (status 1) showed an error card but never called `ErrorReporter`; `ErrorReporter.reportMessage()` is now called with status code, message, and package name
|
||||
- **Screensaver toggle in web settings** — The screensaver row was missing a `<span class="toggle-slider">` inside the `<span class="toggle-switch">` wrapper, so no slider was rendered; corrected to use the same `toggle-row` / `toggle-switch` / `toggle-slider` structure as all other settings toggles
|
||||
- **antiwaste.title translation** — IT and DE locale files were missing the `antiwaste.title` key, causing a raw key string to appear in the anti-waste section header; added to both `it.json` and `de.json`
|
||||
|
||||
### Kiosk (v1.4.0 → v1.5.0)
|
||||
- `autoDiscover()` fully rewritten (CompletionService, NetworkInterface, TCP pre-check, real-time feedback, correct LAN subnet)
|
||||
- Port 443 added to discovery scan
|
||||
- Permissions button transforms after grant (`onPermissionsGranted()`)
|
||||
- `ErrorReporter.init()` called at `SetupActivity.onCreate()`
|
||||
- `ErrorReporter.reportMessage()` called on gateway install failure
|
||||
- `finishSetup()` pre-configures gateway via `save_settings` API call
|
||||
|
||||
## [1.6.0] - 2026-05-03
|
||||
|
||||
### Added
|
||||
- **Dashboard skeleton loading** — Stat cards (Dispensa / Frigo / Freezer) show an animated shimmer placeholder (`…`) instead of the jarring `0` flash that appeared for 3–5 seconds before data loaded; the loading class is applied before the API call and removed atomically when data arrives
|
||||
- **Webapp startup preloader** — Full-screen spinner overlay during initial app load, fades out after the dashboard is ready
|
||||
- **Webapp update notification** — A dismissible top banner alerts the user when a newer GitHub release is available (checked once every 6 hours, comparison based on `published_at`)
|
||||
- **Native Android update banners** — Both Kiosk (v1.4.0) and Scale Gateway (v2.1.0) show a native top bar when a newer APK is available, with one-tap download and install
|
||||
|
||||
### Fixed
|
||||
- **APK install conflict** — Replaced `ACTION_VIEW`-based APK install with the `PackageInstaller.Session` API (API 21+) in both Kiosk and Scale Gateway; the session-based approach correctly handles:
|
||||
- `STATUS_PENDING_USER_ACTION` → automatically launches the system confirmation dialog
|
||||
- `STATUS_SUCCESS` → success toast
|
||||
- `STATUS_FAILURE_CONFLICT` / `STATUS_FAILURE_INCOMPATIBLE` → `AlertDialog` offering to uninstall the old app (signature mismatch) before reinstalling
|
||||
- **Cooking mode z-index** — Update banner and app header are now hidden when `body.cooking-mode-active` is set, and the cooking overlay z-index was raised to `99998` so it can no longer be obscured by UI chrome
|
||||
- **Version-aware error reporting** — GitHub Issues are only created when the client is running the latest released version, avoiding noise from stale deployments; non-semver tag names (e.g. `"latest"`) are treated as "always up-to-date"
|
||||
- **XOR-obfuscated GitHub token** — The PAT used for GitHub API calls is stored as an XOR-encoded hex string in both the PHP backend and Kotlin apps to prevent accidental exposure via secret scanning
|
||||
|
||||
### Kiosk (v1.3.0 → v1.4.0)
|
||||
- FileProvider + `REQUEST_INSTALL_PACKAGES` permission added
|
||||
- APK download destination moved to `getExternalFilesDir(null)` (no storage permission needed)
|
||||
- `PackageInstaller` self-update with signature-conflict recovery
|
||||
- BLE scale gateway update banner with download + install flow
|
||||
|
||||
### Scale Gateway (v2.0.0 → v2.1.0)
|
||||
- Same FileProvider + permission + `PackageInstaller` changes as Kiosk
|
||||
- Update banner for self-update
|
||||
- CI workflow now triggers on `develop` branch (in addition to `main`)
|
||||
|
||||
## [Unreleased] - 2026-04-30
|
||||
|
||||
### Fixed
|
||||
- **Low-qty banner false positive** — A "suspiciously low quantity" review alert is now suppressed for a partially-used inventory entry when one or more sibling entries for the same product (identified by barcode, or name+brand as fallback) exist in other locations with stock > 0. Prevents noise like "191 ml of milk" when 11 sealed packages are stored in the pantry.
|
||||
|
||||
### Changed
|
||||
- **Non-alarmist expired banner** — Banner icon, CSS class, and title suffix now adapt to the `getExpiredSafety()` level:
|
||||
- `ok` (long-life products, freezer within margin): green banner, ✅ icon, "— Scaduto (ancora ok)"
|
||||
- `warning` (items that should be inspected): amber/yellow banner, 👀 icon, "— Scaduto (controlla)"
|
||||
- `danger` (raw meat, dairy, fish, etc.): unchanged red 🚫 banner and "— Scaduto!" title
|
||||
- Added `expiry.expired_suffix_ok` and `expiry.expired_suffix_warning` i18n keys to all three language files (IT/EN/DE)
|
||||
- Added `banner-expired-ok` and `banner-expired-warning` CSS variants (green / amber) in `style.css`
|
||||
|
||||
## [1.5.0] - 2026-04-28
|
||||
|
||||
### Added
|
||||
- **Expired banner for opened products** — Products whose opened-product shelf-life has passed (e.g. fridge cream opened 6 days ago) now appear in the top notification banner, not just the dashboard list
|
||||
- **Safety-aware expired banner** — Each expired banner item shows a contextual safety tip (from `getExpiredSafety()`); danger-level items (fridge dairy/meat/fish) get an intense red banner and "L'ho buttato" as the primary button; safe/warning items keep the original button order
|
||||
- **AI model fallback** — All Gemini API endpoints (expiry scan, product identification, chat, recipe non-streaming, shopping name classifier) now try `gemini-2.5-flash` first and fall back to `gemini-2.0-flash` automatically, matching the resilience already in place for recipe streaming
|
||||
- **Friendly AI quota message** — When the AI returns a quota/rate-limit error the user sees "Quota AI esaurita. Riprova tra qualche minuto." instead of the raw API error string
|
||||
- **Cooking TTS auto-read** — Each recipe step is read aloud automatically when navigating forward or backward; the first step is also read when entering cooking mode
|
||||
- **Cooking timer 10-second warning** — When a cooking timer reaches 10 seconds the TTS announces "Attenzione! [label]: mancano 10 secondi!"
|
||||
- **Cooking recipe completion announcement** — "Ricetta completata! Buon appetito!" is spoken via TTS when the last step is confirmed
|
||||
|
||||
### Fixed
|
||||
- **Cooking TTS gate** — `speakCookingStep()` was blocked by the global `tts_enabled` setting; the `_cookingTTS` toggle (🔊/🔇 button) is now the only gate; browser Web Speech API is used by default without requiring TTS configuration in Settings
|
||||
- **Anomaly dismiss label** — The "La quantità è giusta" button now appends the current inventory quantity, e.g. "La quantità è giusta (2 pz)", so the action is unambiguous
|
||||
- **i18n sync** — Added `timer_warning_tts`, `recipe_done_tts`, `error.ai_quota` keys to all three language files (IT/EN/DE)
|
||||
|
||||
|
||||
### Added
|
||||
- **Generic shopping names** — Products are grouped by type ("Latte", "Affettato", "Pasta") rather than brand; computed via an expanded keyword map with Google Gemini AI as fallback for unknown products
|
||||
- **Bring! auto-migration** — Existing list items with old specific names are silently migrated to generic names on every list load, throttled to once per 10 minutes
|
||||
- **Bring! catalog coverage** — All 93 shopping_name values now resolve to a German Bring! catalog key (icons and categories in the Bring! app); 24 aliases added to cover previously unmatched names
|
||||
- **Auto-add to Bring! on depletion** — When a product reaches zero the app adds it to Bring! automatically using the generic shopping name, with the specific product name and brand in the specification field
|
||||
- **Finished-product confirmation banner** — Instead of silently deleting zero-stock entries, a banner prompts the user to confirm; banner title includes the last 3 digits of the product barcode for easier identification
|
||||
- **Anomaly detection banner** — Dashboard notifications for suspicious inventory/transaction mismatches and consumption prediction errors, with one-tap inline correction
|
||||
- **SSE recipe streaming** — Recipe generation streams live via Server-Sent Events; Gemini agent feedback is shown in real time as it is generated
|
||||
- **Smart alert banners** — Configurable expired-only mode with explanatory messages; banner buttons are fully internationalized
|
||||
|
||||
### Fixed
|
||||
- **Scale double-deduction** — Multiple BLE stable readings of the same weight no longer fire duplicate `inventory_use` events; JS preserves the confirmation sentinel on submit and PHP rejects a second `out` transaction for the same product within 12 seconds
|
||||
- **Kiosk native TTS** — CI workflow now builds the APK on `develop` branch too; the native Android `TextToSpeech` bridge bypasses Web Speech API voice-availability issues without requiring offline voice packs
|
||||
- **TTS voice loading** — Retries for up to 10 seconds on page load; shows a message if no voices are available and offers a manual refresh button
|
||||
- **Bring! migration** — Corrected two bugs: wrong removal API (`DELETE /item` → `PUT remove=item`) and wrong purchase key sent to Bring! (Italian shopping name → German catalog key), which previously created Italian/German duplicate entries
|
||||
- **Gemini 429 rate limiting** — API calls are retried with exponential backoff; recipe requests are capped at 5 per minute with a dedicated rate-limit bucket
|
||||
|
||||
### Performance
|
||||
- **Gemini calls centralized** — All Gemini API requests go through a single `callGemini()` helper with intelligent backoff; Gemini removed from the product-selection and bringSuggest flows in favour of fast offline logic
|
||||
|
||||
## [1.3.0] - 2026-04-18
|
||||
|
||||
### Added
|
||||
- **Expired product banner** — Dashboard notifications for expired products with use, throw away, edit, and dismiss actions
|
||||
- **Expiring soon banner** — Dashboard notifications for products expiring within 3 days with use, edit, and dismiss actions
|
||||
- **Priority-sorted notifications** — Banner alerts sorted by urgency: expired > expiring > suspicious quantities > consumption predictions
|
||||
- **Swipe navigation** — Touch swipe left/right to browse banner notifications, with dot indicators and arrow buttons
|
||||
- **Quick-access buttons** — Inventory page shows 4 recently used and up to 8 most popular products for quick selection
|
||||
- **Recent & popular products API** — New `recent_popular_products` endpoint
|
||||
- **Auto-refresh** — Banner notifications refresh every 5 minutes while on the dashboard
|
||||
- **Edit from expiry banner** — Correct expiry dates directly from expired/expiring notifications
|
||||
|
||||
### Fixed
|
||||
- **Negative scale values** — BLE scale readings with negative weight are now ignored
|
||||
- **Banner re-appearing after edit** — Editing from a banner now persists the confirmation so it doesn't reappear on dashboard reload
|
||||
- **False consumption predictions** — Manual inventory edits (updated_at > last restock) now use the correct baseline for prediction calculations
|
||||
- **Kiosk overlay blocking header** — Removed injected exit/refresh buttons from the web app header in kiosk mode
|
||||
|
||||
## [1.2.0] - 2026-04-13
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
FROM php:8.2-apache
|
||||
|
||||
# Install required PHP extensions
|
||||
# Install required PHP extensions + Tesseract OCR for offline expiry date reading
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libsqlite3-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libonig-dev \
|
||||
&& docker-php-ext-install pdo_sqlite curl mbstring \
|
||||
libgd-dev \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-ita \
|
||||
tesseract-ocr-eng \
|
||||
&& docker-php-ext-install pdo_sqlite curl mbstring gd \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Enable Apache mod_rewrite
|
||||
RUN a2enmod rewrite
|
||||
# Enable Apache mod_rewrite and mod_headers
|
||||
RUN a2enmod rewrite headers
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www/html
|
||||
|
||||
@@ -2,50 +2,130 @@
|
||||
|
||||
> **Self-hosted pantry management system** — Track your food inventory, scan barcodes, get AI-powered recipe suggestions, and reduce waste.
|
||||
|
||||
🌐 **Website:** [evershelfproject.dadaloop.it](https://evershelfproject.dadaloop.it/)
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
### 🚀 Try the live demo — no installation required!
|
||||
|
||||
**[▶ Open Live Demo](https://evershelfproject.dadaloop.it/demo)**
|
||||
·
|
||||
[🌐 Project Website](https://evershelfproject.dadaloop.it/)
|
||||
·
|
||||
[📖 Wiki](https://github.com/dadaloop82/EverShelf/wiki)
|
||||
|
||||
*The demo runs with mock pantry data. AI features are fully enabled. All write operations are safely sandboxed.*
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.php.net/)
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Recent Updates (v1.7.12)
|
||||
|
||||
- **Banner aperto con indicazione posizione** — Nella sezione "Usa prima" il testo ora mostra "Quella nel frigo, aperta da X giorni" invece di una data di scadenza calcolata che poteva risultare confusa.
|
||||
- **Ricette: quantità in pezzi per prodotti pz** — Il prompt AI e la post-elaborazione PHP ora istruiscono Gemini a esprimere `qty_number` come pezzi interi (non grammi) per i prodotti con unità `pz` (es. Pan bauletto, fette biscottate). Il fallback PHP non divide più per 100 quando `default_quantity = 0`.
|
||||
- **Fix: "Usa TUTTO" nelle ricette non elimina più la riga** — Il pulsante "Usa TUTTO / Finito" nella modal di utilizzo ricette inviava `use_all: true` che causava un `DELETE` immediato senza conferma. Ora calcola la quantità esatta dagli item disponibili e fa un normale `inventory_use`.
|
||||
|
||||
- **Scan page redesign** — La pagina di scansione è stata completamente ridisegnata: **2× zoom fisso** (hardware o CSS), **torcia** con feedback visivo, **flip fotocamera** (front/back), **3 tab input** (Barcode / Nome / AI), **prodotti recenti** (ultimi 6 in localStorage), **live code overlay** durante la scansione parziale, **confirm overlay** al successo, **angoli guida** nel viewport.
|
||||
- **AI Number OCR** — Dopo 4 secondi senza scansione compare il bottone "Leggi numeri con AI": Gemini analizza il frame video e restituisce le cifre del barcode anche quando lo scanner ottico non riesce a leggerlo.
|
||||
- **Fix falsi positivi anomalie** — Rimossa la direzione `untracked` dal rilevatore di anomalie; le predizioni di consumo richiedono ora min 5 transazioni e 7 giorni di storico.
|
||||
- **Fix menu suggerimenti scan** — Rimosso il datalist dal campo Nome nella pagina scansione (non più aperto su tablet).
|
||||
- **Fix falsi positivi anomalie consumo** — `getConsumptionPredictions` richiedeva solo 3 transazioni, potendo generare rate esplose su dati ravvicinati. Ora: min 5 txn, min 7gg span, skip se consumo predetto < 15% baseline.
|
||||
|
||||
- **Banner "Imposta scadenza" ora funziona** — Il pulsante sul banner "nessuna scadenza" apriva una funzione inesistente. Corretto, ora apre correttamente la modal di modifica.
|
||||
- **Banner aperto vs scaduto** — I prodotti con `opened_at` mostrano "Aperto da N giorni in [posizione]" invece di "Scaduto!", con la posizione (frigo/dispensa/freezer) esplicitamente indicata.
|
||||
- **Shelf life latte UHT** — Il latte generico è ora trattato come UHT (7 giorni dopo apertura) invece che fresco (4 giorni).
|
||||
- **Niente più false anomalie di consumo** — Il rilevatore ora ignora i casi in cui `expected = 0` (prodotto probabilmente ricomprato) e alza la soglia "more than expected" al 400%. Le notifiche rimangono solo per consumi significativamente inferiori al previsto.
|
||||
- **Scaduti nascondono prodotti già buttati** — La sezione scaduti ora filtra correttamente i prodotti con `quantity = 0`.
|
||||
- **Docker: fix permessi DB al primo avvio** — `_ensureDataDir()` crea la directory `data/` se mancante e tenta `chmod(0775)` se non scrivibile, risolvendo `SQLSTATE[HY000][14]` su volumi Docker freschi.
|
||||
- **AI price estimation for shopping list** — Each Bring! shopping item now shows an estimated retail price badge (unit price + total). Prices are fetched via Gemini AI, cached server-side for 3 months, and stored client-side in `sessionStorage` to survive navigation. The dashboard shopping stat card shows a live green `ca. €X.XX` badge that updates in real-time as prices are calculated — even in background when you're on another tab.
|
||||
- **Kiosk v1.7.0: OTA update system** — "Cerca aggiornamenti" button in Settings triggers a forced GitHub release check; new `installUpdate()` JS bridge calls Android `DownloadManager` directly (lockTask mode blocks external browser links); graceful degradation for older APKs with manual instructions. Automatic OTA check every 6 hours with native update banner.
|
||||
- **Kiosk: consistent APK signing** — Project keystore (`evershelf.jks`) committed to the repo; every build — local or CI — now produces an APK with the same signature, eliminating "APK incompatible / signature conflict" errors on OTA update.
|
||||
- **GitHub Actions: auto-publish kiosk APK** — On every push to `main` that touches `evershelf-kiosk/`, Actions builds the APK and publishes a versioned semver release (`kiosk-X.Y.Z`) plus updates the `kiosk-latest` alias. No more manual release uploads.
|
||||
- **Fix: false "update available" on launch** — `checkForUpdates` now requires a strictly-greater semver tag to flag an update. Non-semver tags (e.g. `kiosk-latest`) no longer trigger a false positive immediately after a fresh install.
|
||||
- **Kiosk: live scale diagnostic panel** — When connected, Settings shows device name, battery %, real-time weight, protocol and reconnection status without leaving the settings page.
|
||||
- **Kiosk: scale dot visible on header** — Connected-state dot changed from green-on-green to white fill + green glow, clearly visible on any background.
|
||||
- **Kiosk: reconfigure BLE scale** — New "Riconfigura bilancia BLE" button in Settings; shows amber notice with download link if the installed APK predates the `reconfigureScale()` bridge method.
|
||||
- **Nutrition analysis dashboard** — Category distribution pie chart (3D conic-gradient), health/variety/freshness score bars, alternates with the anti-waste section hourly.
|
||||
- **Screensaver nutrition panel** — Animated 3D pie + donut ring scores rotate with fact cards every 5 minutes in the screensaver overlay.
|
||||
- **Automatic error reporting** — Unhandled JS errors, Android crashes and PHP exceptions are silently posted to `api/?action=report_error`; the server deduplicates by fingerprint and creates or comments on a GitHub Issue automatically. Crash details are persisted to `SharedPreferences` so even errors that prevent network I/O are sent on the next launch.
|
||||
- **Demo mode (JS)** — Full frontend demo with mock pantry data, Gemini enabled, Bring! writes silently no-op'd; accessible via `?demo=1` or `.env` `DEMO_MODE=true`.
|
||||
- **Graceful Bring! no-key state** — When Bring! credentials are not configured, the shopping tab shows a friendly message with a direct link to Settings instead of a raw error.
|
||||
- **Use-quantity guard** — Consuming more than the stocked quantity at a given location is now blocked client-side with a shake animation on the quantity field.
|
||||
- **Kiosk v1.6.0: BLE scale gateway integrated** — The standalone Scale Gateway app is no longer needed. BLE scanning, GATT connection and the WebSocket server (`:8765`) now run as a built-in `GatewayService` foreground service inside the kiosk app. Setup step 4 shows a live BLE device scan — users select their scale directly, no external APK install required. The external gateway app is deprecated.
|
||||
- **Kiosk setup wizard overhaul** — Auto-discovery rewritten with `ExecutorCompletionService` + `NetworkInterface` (no deprecated `WifiManager`), 60 parallel TCP pre-checks, real-time UI feedback, ports 80/443/8080/8443, correct LAN subnet detection (VPN/cellular interfaces filtered, `wlan`/`eth` prioritised).
|
||||
- **Kiosk permissions flow** — Grant button transforms into a green "✅ Permessi concessi — Continua →" button after permissions are granted instead of just showing a card.
|
||||
- **3 new AI features (Gemini)** — Storage/shelf-life hint shown inline in the add form; AI-enriched shopping suggestions with a short practical tip per item; plain-language anomaly explanation via a "🤖 Spiega" button on anomaly banners.
|
||||
- **Security hardening** — `get_settings` no longer exposes API keys in plain text (boolean flags only); `save_settings` protected by optional `SETTINGS_TOKEN` (validated with `hash_equals`); native `DEMO_MODE` in `.env` blocks all write operations at the PHP router level before any other guard.
|
||||
- **Real-time webapp update detection** — An inline header pill appears when a newer release is on GitHub (checked on load + every 30 min); no intrusive full-page banners.
|
||||
- **Gemini availability flag** — All AI entry points check `_geminiAvailable` before firing; the header button shows a visual no-AI state (greyed + amber dot) when no key is configured.
|
||||
- **Dashboard skeleton loading** — Stat cards show an animated shimmer while data loads instead of a jarring `0` flash for 3–5 seconds.
|
||||
- **APK self-update with conflict recovery** — Both Kiosk and Scale Gateway use the `PackageInstaller` session API for OTA installs; a signature conflict now shows a dialog to uninstall the old version instead of a cryptic failure.
|
||||
- **Smarter low-quantity alerts** — The "suspiciously low quantity" banner is no longer raised for a partially-used entry when the same product has stock in another location.
|
||||
- **Non-alarmist expired banner** — Adapts icon, colour, and title to the actual safety level: green ✅ for long-life products still safe, amber 👀 for items to check, red 🚫 only for genuinely dangerous items.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 📦 Inventory Management
|
||||
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS
|
||||
- **AI identification** — Take a photo and let Google Gemini identify the product
|
||||
- **AI identification** — Take a photo and let Google Gemini identify the product, with suggestions from your existing inventory; gracefully shows a friendly message when AI quota is exhausted instead of a raw API error
|
||||
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
|
||||
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
|
||||
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened
|
||||
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened; opened-product expiry is now also checked when building banner alerts (not just the dashboard section)
|
||||
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items
|
||||
- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("La quantità è giusta (2 pz)")
|
||||
|
||||
### 🤖 AI-Powered (Google Gemini)
|
||||
- **Expiry date reading** — Photograph a label and extract the expiry date automatically
|
||||
- **Product identification** — Point your camera at any product for instant recognition
|
||||
- **Recipe generation** — Get personalized recipes based on what's in your pantry
|
||||
- **Existing product matching** — AI scan shows matching products already in your pantry before suggesting new ones
|
||||
- **Storage & shelf-life hint** — When adding a new product, Gemini suggests the optimal storage location and shelf-life in the background; shown as an inline AI badge next to the expiry estimate
|
||||
- **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated
|
||||
- **Smart chat assistant** — Ask questions about your inventory, get cooking tips
|
||||
- **Shopping suggestions** — AI-powered purchase recommendations
|
||||
- **Shopping suggestions with tips** — AI-powered purchase recommendations, each enriched with a short practical buying/storing tip
|
||||
- **Anomaly explanation** — "🤖 Spiega" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
|
||||
- **Model fallback** — All AI endpoints try `gemini-2.5-flash` first and fall back to `gemini-2.0-flash` automatically
|
||||
- **Graceful no-key state** — When no Gemini key is configured, AI entry points show a friendly message; the header button is visually greyed with an amber dot
|
||||
|
||||
### 🛒 Shopping List
|
||||
- **Bring! integration** — Sync with the [Bring!](https://www.getbring.com/) shopping list app
|
||||
- **Generic shopping names** — Products are grouped by type ("Latte", "Affettato", "Panna da cucina") rather than brand, keeping the Bring! list clean and consolidated
|
||||
- **Smart predictions** — Know what you'll need before you run out
|
||||
- **Auto-remove on scan** — Products are removed from the shopping list when scanned in
|
||||
- **DupliClick integration** — Online grocery ordering (Gruppo Poli)
|
||||
- **Auto-add on depletion** — When a product reaches zero the app adds it to Bring! automatically, no confirmation needed
|
||||
- **Auto-remove on scan** — Products are removed from the shopping list when scanned in - **Auto-migration** — Items already on the Bring! list are silently renamed to their generic name in the background (throttled, runs on list load)
|
||||
- **Catalog coverage** — All product types resolve to a German Bring! catalog key for icon and category display in the Bring! app
|
||||
|
||||
### 🍳 Cooking Mode
|
||||
- **Step-by-step guidance** — Follow recipes with a hands-free cooking interface
|
||||
- **Text-to-Speech** — Voice readout of recipe steps (configurable TTS endpoint)
|
||||
- **Text-to-Speech** — Voice readout of recipe steps; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.); retries voice loading for up to 10 seconds with a fallback refresh button; TTS activates automatically without requiring the global TTS setting to be enabled
|
||||
- **Auto-read on navigate** — Each step is read aloud automatically when you tap Next or Previous; the first step is read when entering cooking mode
|
||||
- **Timer voice alerts** — 10-second countdown warning spoken aloud before each timer expires; expiry announced vocally when time is up
|
||||
- **Recipe completion** — "Buon appetito!" spoken when the last step is confirmed
|
||||
- **Built-in timer** — Automatic timer suggestions based on recipe instructions
|
||||
- **Ingredient tracking** — Mark ingredients as used during cooking
|
||||
- **Ingredient tracking** — Mark ingredients as used during cooking; leftover quantities prompt a "move to another location" flow
|
||||
|
||||
### 📊 Dashboard
|
||||
- **Waste tracking** — Monitor consumed vs. wasted products over 30 days
|
||||
- **Anti-waste report** — Personalised waste rate vs. national average with annual kg estimate; shown above the expiring-items list
|
||||
- **Expiry alerts** — Visual warnings for expired and soon-to-expire items
|
||||
- **Safety ratings** — Smart assessment of expired product safety (by category)
|
||||
- **Opened products panel** — Tracks partially-used items; expiry is recalculated from the opening date using AI (Gemini) + per-category rule fallback; whole sealed packages always keep their original manufacturer expiry; conf items with mixed whole + fractional units are shown as two separate entries
|
||||
- **Freezer shelf-life** — Granular per-product estimates (USDA/EFSA): fish 120 d, poultry 270 d, whole red-meat cuts 365 d, mince 120 d, vegetables/fruit 270 d, generic 180 d; AI + cache still take priority over rules
|
||||
- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and "L'ho buttato" as the primary action
|
||||
- **Expired product banner** — Products that have passed their effective shelf-life (including opened-product reduced expiry) appear in the top notification banner; icon, colour and title adapt to the actual safety level (✅ green for safe, 👀 amber to check, 🚫 red for danger); high-risk items get a prominent discard action
|
||||
- **Quick recipe bar** — One-tap recipe suggestion using expiring products
|
||||
- **Anomaly banner** — Scrollable banner with suspicious quantities and consumption prediction mismatches, with one-tap correction or inline edit
|
||||
- **Expired/expiring alerts** — Priority-sorted banner notifications for expired and soon-to-expire products with use, throw, edit, and dismiss actions
|
||||
- **Swipe navigation** — Touch swipe or tap arrows/dots to browse banner notifications
|
||||
- **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access
|
||||
|
||||
### 📱 Progressive Web App
|
||||
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
||||
@@ -54,10 +134,28 @@
|
||||
|
||||
### ⚖️ Smart Scale Integration (Add-on)
|
||||
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
|
||||
- **Auto weight reading** — When adding/using a product with unit g/ml, tap "⚖️ Read from scale"
|
||||
- **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues
|
||||
- **Auto-discovery** — Server scans LAN to find the gateway automatically
|
||||
- **Auto weight reading** — When adding/using a product with unit g/ml, weight fills automatically
|
||||
- **10g threshold** — Ignores readings that haven't changed enough between products - **Duplicate-reading prevention** — Server-side 12-second dedup window rejects a second scale-triggered deduction of the same product, guarding against BLE multi-fire- **ml conversion hint** — Shows "weight in grams → will be converted to ml" when product unit is ml
|
||||
- **Stability + auto-confirm** — 10s stable wait + 5s countdown before confirming
|
||||
- **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
|
||||
- **Android gateway app** — [`evershelf-scale-gateway/`](evershelf-scale-gateway/) — open-source, downloadable APK
|
||||
- **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)
|
||||
- **Dedicated tablet app** — Full-screen WebView wrapper for wall-mounted kitchen tablets
|
||||
- **True kiosk lock** — Screen pinning blocks home/recent buttons
|
||||
- **Setup wizard** — 6-step guided configuration (language, welcome, permissions, server URL, BLE scale scan, screensaver, summary)
|
||||
- **Smart auto-discovery** — Scans the LAN in parallel (60 threads, TCP pre-check, ports 80/443/8080/8443) with real-time UI feedback; correctly identifies the device's Wi-Fi/Ethernet subnet (VPN and cellular interfaces are filtered out)
|
||||
- **Built-in BLE scale gateway** — `GatewayService` foreground service; BLE scanning + WebSocket server `:8765` run directly inside the kiosk app. Select your scale in step 5 of the wizard — no external app required
|
||||
- **Scale auto-configuration** — After selecting the BLE device, the wizard writes `scale_enabled` and `scale_gateway_url=ws://127.0.0.1:8765` to the server automatically
|
||||
- **Camera & mic permissions** — Full hardware access for barcode scanning and voice; grant button transforms to a green confirmation after granting
|
||||
- **Native TTS bridge** — Cooking mode voice readout uses the Android TextToSpeech engine directly, bypassing Web Speech API voice limitations; no offline voice packs required
|
||||
- **Hard refresh** — ↻ button clears WebView cache to pick up web app updates
|
||||
- **Update notifications** — Checks GitHub releases every 6h, shows banner when updates available
|
||||
- **SSL support** — Accepts self-signed certificates
|
||||
- **Android kiosk app** — [`evershelf-kiosk/`](evershelf-kiosk/) — downloadable APK
|
||||
|
||||
---
|
||||
|
||||
@@ -120,6 +218,13 @@ BRING_PASSWORD=your_password
|
||||
TTS_URL=http://your-home-assistant:8123/api/events/tts_speak
|
||||
TTS_TOKEN=your_long_lived_token
|
||||
TTS_ENABLED=true
|
||||
|
||||
# Optional: Security — protect the save_settings endpoint
|
||||
# Set a strong random string; the Settings UI will ask for it before saving
|
||||
SETTINGS_TOKEN=
|
||||
|
||||
# Optional: Demo mode — block all write operations at the router level
|
||||
DEMO_MODE=false
|
||||
```
|
||||
|
||||
### Web Server Configuration
|
||||
@@ -218,9 +323,13 @@ evershelf/
|
||||
├── backups/ # Local DB backups
|
||||
└── *.json # Token/cache files
|
||||
|
||||
evershelf-scale-gateway/ # ⚖️ Android BLE gateway (add-on)
|
||||
├── README.md # Setup & protocol docs
|
||||
evershelf-scale-gateway/ # ⚖️ Android BLE gateway [DEPRECATED — integrated into kiosk v1.6.0+]
|
||||
├── README.md # Deprecation notice + legacy docs
|
||||
└── app/src/ # Kotlin Android source (WebSocket + BLE)
|
||||
|
||||
evershelf-kiosk/ # 📺 Android kiosk app (add-on)
|
||||
├── README.md # Setup & feature docs
|
||||
└── app/src/ # Kotlin Android source (WebView wrapper)
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
@@ -239,6 +348,9 @@ evershelf-scale-gateway/ # ⚖️ Android BLE gateway (add-on)
|
||||
| | `gemini_expiry` | POST | Read expiry date from photo |
|
||||
| | `gemini_chat` | POST | Chat with AI assistant |
|
||||
| | `generate_recipe` | POST | Generate recipe from inventory |
|
||||
| | `gemini_product_hint` | POST | Storage location + shelf-life hint |
|
||||
| | `gemini_shopping_enrich` | POST | Enrich shopping suggestions with tips |
|
||||
| | `gemini_anomaly_explain` | POST | Plain-language anomaly explanation |
|
||||
| **Shopping** | `bring_list` | GET | Get Bring! shopping list |
|
||||
| | `bring_add` | POST | Add items to Bring! |
|
||||
| | `smart_shopping` | GET | Smart shopping predictions |
|
||||
@@ -251,10 +363,12 @@ evershelf-scale-gateway/ # ⚖️ Android BLE gateway (add-on)
|
||||
|
||||
- **Credentials** are stored in `.env` (server-side, never committed to Git)
|
||||
- **Database** stays local — never pushed to remote repositories
|
||||
- **API keys** are passed server-side only — never exposed to the browser
|
||||
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `settings_token_set`), never raw key values
|
||||
- **Settings write protection** — set `SETTINGS_TOKEN` in `.env` to require a secret token (`X-Settings-Token` header) for all `save_settings` calls; validated with `hash_equals` to prevent timing attacks
|
||||
- **Demo / public mode** — set `DEMO_MODE=true` to block all write operations at the PHP router level before any business logic runs
|
||||
- The API uses **parameterized SQL queries** (PDO prepared statements) against injection
|
||||
- **Input validation** on all inventory operations (quantity bounds, location whitelist)
|
||||
- Consider adding **authentication** if the server is accessible from the internet
|
||||
- Consider adding **reverse-proxy authentication** (e.g. Authelia, Nginx `auth_basic`) if the server is accessible from the internet
|
||||
|
||||
---
|
||||
|
||||
@@ -282,6 +396,25 @@ The application uses no build tools — edit files directly and refresh.
|
||||
- [x] First-run setup wizard — 4-step guided configuration
|
||||
- [x] API rate limiting — file-based, 3 tiers (120/15/5 req/min)
|
||||
- [x] CI/CD pipeline — GitHub Actions (lint, Docker build, translation validation)
|
||||
- [x] Android kiosk mode — dedicated tablet app with screen pinning
|
||||
- [x] Anomaly detection banner — suspicious quantities + consumption predictions
|
||||
- [x] AI scan local matching — suggest existing pantry products before OFF lookup
|
||||
- [x] Scale auto-fill improvements — 10g threshold, ml conversion hints
|
||||
- [x] Update notification system — inline header pill (webapp) + kiosk checks GitHub releases
|
||||
- [x] Kiosk OTA update — forced check button, `installUpdate()` bridge, graceful old-APK fallback
|
||||
- [x] Kiosk consistent APK signing — project keystore eliminates signature conflicts on OTA
|
||||
- [x] GitHub Actions kiosk CI — auto-builds and publishes versioned semver APK on every push to main
|
||||
- [x] Kiosk live scale diagnostics — device, battery, real-time weight in Settings when connected
|
||||
- [x] Nutrition analysis dashboard — category pie + health/variety/freshness scores, alternates with waste section
|
||||
- [x] Screensaver nutrition panel — animated pie + donut ring scores rotate with facts
|
||||
- [x] Automatic error reporting — JS/Android/PHP errors → GitHub Issues with deduplication
|
||||
- [x] Generic shopping name grouping — compound-phrase + keyword map (100+ entries) + Gemini AI fallback
|
||||
- [x] Auto-add to Bring! on product depletion — no confirmation step when stock reaches zero
|
||||
- [x] Native Android TTS in kiosk — bypasses Web Speech API voice detection issues
|
||||
- [x] AI product storage hint — background Gemini call suggests location + shelf-life in the add form
|
||||
- [x] AI shopping tips enrichment — each suggestion enriched with a short practical tip
|
||||
- [x] AI anomaly explanation — "🤖 Spiega" button explains discrepancies in plain language
|
||||
- [x] Security hardening — no raw key exposure, SETTINGS_TOKEN auth, DEMO_MODE native blocking
|
||||
- [ ] Offline mode with service worker
|
||||
- [ ] Export/import inventory data
|
||||
- [ ] Notification system (Telegram, email) for expiring products
|
||||
|
||||
@@ -39,8 +39,50 @@ try {
|
||||
throw new RuntimeException('Cannot write cache file: ' . CACHE_FILE);
|
||||
}
|
||||
|
||||
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . count($decoded['items'] ?? []) . " items cached\n";
|
||||
$itemCount = count($decoded['items'] ?? []);
|
||||
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . $itemCount . " items cached\n";
|
||||
|
||||
// ── Bring! server-side cleanup ────────────────────────────────────────
|
||||
// After computing smart shopping, automatically remove stale Bring! items
|
||||
// and add/update critical ones. This runs fully server-side every cron cycle.
|
||||
try {
|
||||
$cleanupResult = bringCleanupObsolete($db);
|
||||
if (isset($cleanupResult['skipped'])) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! cleanup skipped: ' . $cleanupResult['skipped'] . "\n";
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! cleanup — removed: ' . ($cleanupResult['removed'] ?? 0)
|
||||
. '/' . ($cleanupResult['candidates'] ?? 0) . ' candidates'
|
||||
. ($cleanupResult['errors'] ? ', errors: ' . $cleanupResult['errors'] : '') . "\n";
|
||||
}
|
||||
|
||||
$addResult = bringAutoAddCritical($db);
|
||||
if (isset($addResult['skipped'])) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add skipped: ' . $addResult['skipped'] . "\n";
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add — added: ' . ($addResult['added'] ?? 0)
|
||||
. ', updated specs: ' . ($addResult['updated'] ?? 0) . "\n";
|
||||
}
|
||||
} catch (Throwable $be) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// ── Shelf life pre-warming ────────────────────────────────────────────
|
||||
// Pre-warm the opened shelf life cache for opened items not yet cached.
|
||||
// Capped at 5 items per cron cycle to avoid Gemini rate limits.
|
||||
try {
|
||||
$prewarmResult = prewarmShelfLifeCache($db, 5);
|
||||
if ($prewarmResult['warmed'] > 0) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm — warmed: ' . $prewarmResult['warmed']
|
||||
. ', skipped: ' . $prewarmResult['skipped'] . "\n";
|
||||
}
|
||||
} catch (Throwable $pe) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm warning: ' . $pe->getMessage() . "\n";
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $e->getMessage() . "\n";
|
||||
$msg = $e->getMessage();
|
||||
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
|
||||
// Report to GitHub Issues (uses the same _phpErrorReport from index.php)
|
||||
_phpErrorReport($msg, $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,46 @@
|
||||
|
||||
define('DB_PATH', __DIR__ . '/../data/evershelf.db');
|
||||
|
||||
/**
|
||||
* Ensure the data directory exists and is writable by the web-server user.
|
||||
* This is needed when a Docker volume is first mounted: the image's chown
|
||||
* step is applied to the image layer, but a fresh named volume starts empty
|
||||
* (owned by root), making SQLite's PDO::__construct fail with HY000[14].
|
||||
*/
|
||||
function _ensureDataDir(): void {
|
||||
$dir = dirname(DB_PATH);
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
throw new \RuntimeException("Cannot create data directory: $dir");
|
||||
}
|
||||
}
|
||||
if (!is_writable($dir)) {
|
||||
// Try to fix permissions (only works when running as root, e.g. first boot)
|
||||
@chmod($dir, 0775);
|
||||
if (!is_writable($dir)) {
|
||||
throw new \RuntimeException(
|
||||
"Data directory is not writable: $dir — run: chown -R www-data:www-data $dir"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Ensure backups sub-directory exists too
|
||||
$backups = $dir . '/backups';
|
||||
if (!is_dir($backups)) {
|
||||
@mkdir($backups, 0775, true);
|
||||
}
|
||||
}
|
||||
|
||||
function getDB(): PDO {
|
||||
_ensureDataDir();
|
||||
$isNew = !file_exists(DB_PATH);
|
||||
$db = new PDO('sqlite:' . DB_PATH);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec("PRAGMA journal_mode=WAL");
|
||||
$db->exec("PRAGMA foreign_keys=ON");
|
||||
$db->exec("PRAGMA synchronous=NORMAL"); // faster writes, still safe with WAL
|
||||
$db->exec("PRAGMA cache_size=-8000"); // ~8 MB page cache (was 2 MB)
|
||||
$db->exec("PRAGMA temp_store=MEMORY"); // temp tables in RAM
|
||||
|
||||
if ($isNew) {
|
||||
initializeDB($db);
|
||||
@@ -39,6 +72,7 @@ function initializeDB(PDO $db): void {
|
||||
unit TEXT DEFAULT 'pz',
|
||||
default_quantity REAL DEFAULT 1,
|
||||
notes TEXT DEFAULT '',
|
||||
shopping_name TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -70,6 +104,11 @@ function initializeDB(PDO $db): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_location ON inventory(location);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at);
|
||||
-- Composite indexes for hot queries
|
||||
-- getStats(): WHERE type IN (...) AND created_at >= ...
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at);
|
||||
-- smartShopping(): GROUP BY product_id filtering on type+undone
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone);
|
||||
");
|
||||
}
|
||||
|
||||
@@ -80,6 +119,9 @@ function migrateDB(PDO $db): void {
|
||||
if (!in_array('package_unit', $colNames)) {
|
||||
$db->exec("ALTER TABLE products ADD COLUMN package_unit TEXT DEFAULT ''");
|
||||
}
|
||||
if (!in_array('shopping_name', $colNames)) {
|
||||
$db->exec("ALTER TABLE products ADD COLUMN shopping_name TEXT DEFAULT ''");
|
||||
}
|
||||
|
||||
// Migrate transactions CHECK constraint to allow 'waste' type
|
||||
$sql = $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='transactions'")->fetchColumn();
|
||||
@@ -101,6 +143,8 @@ function migrateDB(PDO $db): void {
|
||||
$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_date ON transactions(created_at)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)");
|
||||
}
|
||||
|
||||
// --- New shared tables ---
|
||||
@@ -155,25 +199,53 @@ function migrateDB(PDO $db): void {
|
||||
// Add opened_at column to inventory if missing
|
||||
if (!in_array('opened_at', $invColNames)) {
|
||||
$db->exec("ALTER TABLE inventory ADD COLUMN opened_at DATETIME DEFAULT NULL");
|
||||
// Backfill: detect already-opened items and set opened_at + recalculate expiry
|
||||
// Backfill: detect already-opened fridge items and set opened_at.
|
||||
// Only frigo items — pantry/freezer fractional quantities don't imply opened.
|
||||
backfillOpenedItems($db);
|
||||
}
|
||||
|
||||
// Migration: undo incorrect backfill for non-frigo items.
|
||||
// The original backfill also tagged dispensa/freezer items as opened, which overwrote
|
||||
// their manufacturer expiry_date with a short estimated value. Clear opened_at so they
|
||||
// return to the sealed section; clear expiry_date so users can re-enter the real date.
|
||||
$migDone = $db->query("SELECT value FROM app_settings WHERE key = 'migration_fix_nonfrigo_opened_v1'")->fetchColumn();
|
||||
if (!$migDone) {
|
||||
$db->exec("UPDATE inventory SET opened_at = NULL, expiry_date = NULL
|
||||
WHERE location NOT IN ('frigo') AND opened_at IS NOT NULL");
|
||||
$db->exec("INSERT OR REPLACE INTO app_settings (key, value)
|
||||
VALUES ('migration_fix_nonfrigo_opened_v1', '1')");
|
||||
}
|
||||
|
||||
// Migration v2: recalculate sealed fridge item expiry (fridge extends shelf life)
|
||||
$migrated = $db->query("SELECT value FROM app_settings WHERE key = 'migration_fridge_expiry_v1'")->fetchColumn();
|
||||
if (!$migrated) {
|
||||
recalcSealedFridgeExpiry($db);
|
||||
$db->exec("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('migration_fridge_expiry_v1', '1')");
|
||||
}
|
||||
|
||||
// Add undone column to transactions if missing
|
||||
$txCols = $db->query("PRAGMA table_info(transactions)")->fetchAll();
|
||||
$txColNames = array_column($txCols, 'name');
|
||||
if (!in_array('undone', $txColNames)) {
|
||||
$db->exec("ALTER TABLE transactions ADD COLUMN undone INTEGER DEFAULT 0");
|
||||
}
|
||||
|
||||
// Ensure composite indexes exist (added in v1.7.5 for performance)
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill opened_at for existing inventory items that appear to be opened.
|
||||
* Backfill opened_at for frigo items that appear to be opened.
|
||||
* An item is considered opened if:
|
||||
* - conf unit with fractional quantity
|
||||
* - weight/volume unit (g,kg,ml,l) with quantity < default_quantity
|
||||
* Uses updated_at as the approximate opened_at date.
|
||||
* Recalculates expiry_date based on opened shelf life from opened_at.
|
||||
* Does NOT overwrite expiry_date — the manufacturer date is preserved;
|
||||
* getStats computes opened expiry on-the-fly from opened_at.
|
||||
*
|
||||
* Only frigo items: pantry/freezer fractional quantities are normal
|
||||
* (e.g. 3 of 6 UHT milks) and do not indicate a food-safety expiry change.
|
||||
*/
|
||||
function backfillOpenedItems(PDO $db): void {
|
||||
$stmt = $db->query("
|
||||
@@ -181,7 +253,7 @@ function backfillOpenedItems(PDO $db): void {
|
||||
p.name, p.category, p.unit, p.default_quantity
|
||||
FROM inventory i
|
||||
JOIN products p ON i.product_id = p.id
|
||||
WHERE i.quantity > 0
|
||||
WHERE i.quantity > 0 AND i.location = 'frigo'
|
||||
");
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
@@ -200,15 +272,9 @@ function backfillOpenedItems(PDO $db): void {
|
||||
|
||||
if (!$isOpened) continue;
|
||||
|
||||
$openedAt = $row['updated_at'];
|
||||
$openedDays = estimateOpenedExpiryDaysPHP($row['name'], $row['category'], $row['location']);
|
||||
if ($row['vacuum_sealed']) $openedDays = (int)round($openedDays * 1.5);
|
||||
|
||||
// Calculate new expiry from opened_at
|
||||
$newExpiry = date('Y-m-d', strtotime($openedAt . " +{$openedDays} days"));
|
||||
|
||||
$upd = $db->prepare("UPDATE inventory SET opened_at = ?, expiry_date = ? WHERE id = ?");
|
||||
$upd->execute([$openedAt, $newExpiry, $row['id']]);
|
||||
// Only set opened_at — do NOT touch expiry_date (manufacturer date is preserved)
|
||||
$upd = $db->prepare("UPDATE inventory SET opened_at = ? WHERE id = ? AND opened_at IS NULL");
|
||||
$upd->execute([$row['updated_at'], $row['id']]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,8 +311,37 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\b(lenticchie|ceci|fagioli|piselli)\b/', $n) && !preg_match('/\b(cotto|vapore|scatola)\b/', $n)) return 365;
|
||||
}
|
||||
|
||||
// ── D: Freezer ───────────────────────────────────────────────────────
|
||||
if ($loc === 'freezer') return 90;
|
||||
// ── D: Freezer — per-product estimates (USDA/EFSA guidelines) ───────
|
||||
if ($loc === 'freezer') {
|
||||
// Bread, pastry, dough
|
||||
if (preg_match('/\b(pane|bread|toast|brioche|ciabatta|baguette|focaccia|pizza\s*base|impasto)\b/', $n)) return 90;
|
||||
if (preg_match('/\b(pasta\s+fresca|gnocchi|ravioli|tortellini|lasagna\s+fresca)\b/', $n)) return 60;
|
||||
if (preg_match('/\b(croissant|cornetto|pasticceria|dolce|torta|plumcake|muffin|biscotti)\b/', $n)) return 90;
|
||||
// Ice cream / sorbet
|
||||
if (preg_match('/\b(gelato|sorbetto|ice\s*cream|ghiacciolo)\b/', $n)) return 365;
|
||||
// Fish & seafood — shorter (3–6 months)
|
||||
if (preg_match('/\b(salmone|trota|spigola|orata|tonno|merluzzo|baccalà|nasello|sgombro|pesce|calamaro|gambero|gamberetti|polpo|seppia|cozza|vongola|frutti\s+di\s+mare|seafood)\b/', $n)) return 120;
|
||||
// Poultry — 9 months
|
||||
if (preg_match('/\b(pollo|tacchino|anatra|faraona|petto\s+di\s+pollo|coscia|fesa)\b/', $n)) return 270;
|
||||
// Red meat whole cuts — 12 months
|
||||
if (preg_match('/\b(manzo|vitello|agnello|maiale|lonza|costata|arrosto|fesa|fettina|bistecca)\b/', $n)) return 365;
|
||||
// Ground meat / mince — 3–4 months
|
||||
if (preg_match('/\b(macinato|macinata|hamburger|polpette|ragù)\b/', $n)) return 120;
|
||||
// Sausage / cured meat frozen
|
||||
if (preg_match('/\b(salsiccia|würstel|wurstel|salame|pancetta|speck|prosciutto)\b/', $n)) return 60;
|
||||
// Dairy
|
||||
if (preg_match('/\b(burro)\b/', $n)) return 270;
|
||||
if (preg_match('/\b(panna)\b/', $n)) return 90;
|
||||
if (preg_match('/\b(formaggio|mozzarella|ricotta)\b/', $n)) return 90;
|
||||
// Vegetables (blanched/processed for freezer)
|
||||
if (preg_match('/\b(piselli|fagioli|fagiolini|spinaci|broccoli|cavolfiore|carote|mais|edamame|verdure\s+miste|minestrone)\b/', $n)) return 270;
|
||||
// Fruits
|
||||
if (preg_match('/\b(fragole|lamponi|mirtilli|more|ciliegia|frutta\s+mista|frutta)\b/', $n)) return 270;
|
||||
// Stocks, soups, sauces (already cooked)
|
||||
if (preg_match('/\b(brodo|zuppa|minestra|sugo|salsa|passata)\b/', $n)) return 180;
|
||||
// Generic freezer fallback
|
||||
return 180;
|
||||
}
|
||||
|
||||
// ── E: Pantry/dispensa — specific products then generic fallback ─────
|
||||
if ($loc !== 'frigo') {
|
||||
@@ -257,14 +352,21 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\bpane\b/', $n)) return 4;
|
||||
// Specific jarred tomato sauce in pantry (opened, not refrigerated)
|
||||
if (preg_match('/salsa\s+di\s+(pomodoro|pronta)/', $n)) return 5;
|
||||
return 60; // generic pantry fallback (was 30, doubled)
|
||||
// Dairy opened outside fridge: bad very quickly at room temperature
|
||||
if (preg_match('/\bpanna\b/', $n)) return 3;
|
||||
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 2;
|
||||
if (preg_match('/\blatte\b/', $n)) return 1;
|
||||
if (preg_match('/\bformaggio\b/', $n)) return 2;
|
||||
return 60; // generic pantry fallback
|
||||
}
|
||||
|
||||
// ── F: Fridge — short-life perishables ──────────────────────────────
|
||||
if (preg_match('/latte\s+(fresco|intero|parzial|scremato)/', $n)) return 3;
|
||||
if (preg_match('/latte\s+(uht|a\s+lunga)/', $n)) return 5;
|
||||
if (preg_match('/\blatte\b/', $n)) return 4;
|
||||
if (preg_match('/\byogurt\b/', $n)) return 5;
|
||||
if (preg_match('/latte\s+(uht|a\s+lunga)/', $n)) return 7;
|
||||
// Long-life mountain/brand milks stored in pantry before use (UHT)
|
||||
if (preg_match('/latte.*(montagna|alta\s+qual|parmalat|granarolo|esselunga|conservaz|microfiltrat)/i', $n)) return 7;
|
||||
if (preg_match('/\blatte\b/', $n)) return 7; // generic: default to UHT (most common in IT households)
|
||||
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 5;
|
||||
if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 3;
|
||||
if (preg_match('/philadelphia|spalmabile/', $n)) return 7;
|
||||
if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5;
|
||||
@@ -277,25 +379,26 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\b(pollo|tacchino|maiale|manzo|vitello|agnello)\b/', $n)) return 2;
|
||||
if (preg_match('/salmone|tonno\s+fresco|pesce(?!\s+in)/', $n)) return 2;
|
||||
if (preg_match('/\b(passata|pelati|polpa|sugo|salsa\s+di\s+pomodoro)\b/', $n)) return 5;
|
||||
if (preg_match('/insalata|rucola|spinaci|lattuga|crescione|germogli/', $n)) return 2;
|
||||
if (preg_match('/insalata|rucola|spinaci|lattuga|crescione|germogli/', $n)) return 4;
|
||||
if (preg_match('/\b(succo|spremuta)\b/', $n)) return 3;
|
||||
if (preg_match('/\b(birra|beer)\b/', $n)) return 3;
|
||||
if (preg_match('/\bvino\b/', $n)) return 5;
|
||||
if (preg_match('/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/', $n)) return 4;
|
||||
// Fruit opened/cut in fridge — much shorter than sealed
|
||||
if (preg_match('/\bavocado\b/', $n)) return 2;
|
||||
if (preg_match('/\b(banana|banane|fragola|lampone|pesca|albicocca|ciliegia|mango|papaya)\b/', $n)) return 2;
|
||||
if (preg_match('/\b(mela|pera|nettarina|prugna|kiwi|ananas|uva|melone|anguria)\b/', $n)) return 3;
|
||||
if (preg_match('/\b(arancia|mandarino|pompelmo|clementina|limone)\b/', $n)) return 3; // cut citrus
|
||||
// Vegetables opened/cut in fridge
|
||||
if (preg_match('/\b(zucchina|zucchine|melanzana|pomodor)\b/', $n)) return 3;
|
||||
if (preg_match('/\b(peperone|peperoni)\b/', $n)) return 3;
|
||||
if (preg_match('/\b(broccolo|broccoli|cavolfiore|cavolo)\b/', $n)) return 3;
|
||||
if (preg_match('/\bsedano\b|\bfinocchio\b/', $n)) return 3;
|
||||
if (preg_match('/\b(cipolla|cipolle|cipollotto|scalogno|porro)\b/', $n)) return 4;
|
||||
if (preg_match('/\b(carota|carote)\b/', $n)) return 5;
|
||||
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 3; // cooked/cut potato
|
||||
if (preg_match('/\baglio\b/', $n)) return 10;
|
||||
// Fruit in fridge (opened pack, not necessarily cut)
|
||||
if (preg_match('/\bavocado\b/', $n)) return 3;
|
||||
if (preg_match('/\b(fragola|fragole|lampone|lamponi|mirtillo|mirtilli|mora|more)\b/', $n)) return 4;
|
||||
if (preg_match('/\b(banana|banane|pesca|pesche|albicocca|albicocche|ciliegia|ciliegie|mango|papaya)\b/', $n)) return 4;
|
||||
if (preg_match('/\b(mela|mele|pera|pere|nettarina|prugna|kiwi|ananas|uva|melone|anguria)\b/', $n)) return 5;
|
||||
if (preg_match('/\b(arancia|arance|mandarino|mandarini|pompelmo|clementina|limone|limoni)\b/', $n)) return 7;
|
||||
// Vegetables in fridge (opened pack)
|
||||
if (preg_match('/\b(zucchina|zucchine|melanzana|melanzane|pomodor)\b/', $n)) return 5;
|
||||
if (preg_match('/\b(peperone|peperoni)\b/', $n)) return 5;
|
||||
if (preg_match('/\b(broccolo|broccoli|cavolfiore|cavolo)\b/', $n)) return 4;
|
||||
if (preg_match('/\bsedano\b|\bfinocchio\b/', $n)) return 5;
|
||||
if (preg_match('/\b(cipolla|cipolle|cipollotto|scalogno|porro)\b/', $n)) return 6;
|
||||
if (preg_match('/\b(carota|carote)\b/', $n)) return 7;
|
||||
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 4;
|
||||
if (preg_match('/\baglio\b/', $n)) return 14;
|
||||
|
||||
// ── G: Fridge condiments — medium shelf-life ─────────────────────────
|
||||
if (preg_match('/maionese|mayo|mayon/', $n)) return 90;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf Scale Gateway — Auto-discovery
|
||||
*
|
||||
* Scans the server's local /24 subnet for any host responding on the gateway
|
||||
* port (default 8765) and confirms it with a WebSocket handshake.
|
||||
*
|
||||
* Returns: {"found": ["ws://192.168.1.100:8765", ...]}
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache');
|
||||
|
||||
$port = (int)($_GET['port'] ?? 8765);
|
||||
if ($port < 1 || $port > 65535) $port = 8765;
|
||||
|
||||
// ── Determine server LAN IP ────────────────────────────────────────────────
|
||||
// SERVER_ADDR may be 127.0.0.1 when accessed via internal vhost — fall back
|
||||
// to a UDP trick (no actual packet sent) to find the default-route interface IP.
|
||||
function localLanIp(): string {
|
||||
$sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
|
||||
if ($sock) {
|
||||
@socket_connect($sock, '8.8.8.8', 53);
|
||||
@socket_getsockname($sock, $ip);
|
||||
socket_close($sock);
|
||||
if (isset($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
|
||||
}
|
||||
// Fallback: parse /proc/net/route for default gateway interface then ip neigh
|
||||
$ifaces = @net_get_interfaces();
|
||||
if ($ifaces) {
|
||||
foreach ($ifaces as $name => $info) {
|
||||
if ($name === 'lo') continue;
|
||||
foreach ($info['unicast'] ?? [] as $u) {
|
||||
$ip = $u['address'] ?? '';
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE)) continue;
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
$serverIp = localLanIp();
|
||||
$parts = explode('.', $serverIp);
|
||||
if (count($parts) !== 4) {
|
||||
echo json_encode(['error' => 'Cannot determine local subnet', 'server_ip' => $serverIp]);
|
||||
exit;
|
||||
}
|
||||
$subnet = $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.';
|
||||
|
||||
// ── Phase 1: Async TCP connect to all 254 hosts ────────────────────────────
|
||||
// Non-blocking stream_socket_client + stream_select to detect open ports quickly.
|
||||
// Total scan budget: 1.5 seconds.
|
||||
|
||||
$candidates = [];
|
||||
for ($i = 1; $i <= 254; $i++) {
|
||||
$ip = $subnet . $i;
|
||||
$sock = @stream_socket_client(
|
||||
"tcp://{$ip}:{$port}", $errno, $errstr, 0,
|
||||
STREAM_CLIENT_ASYNC_CONNECT | STREAM_CLIENT_CONNECT
|
||||
);
|
||||
if ($sock !== false) {
|
||||
stream_set_blocking($sock, false);
|
||||
$candidates[$ip] = $sock;
|
||||
}
|
||||
}
|
||||
|
||||
$found_tcp = [];
|
||||
$deadline = microtime(true) + 1.5;
|
||||
|
||||
while (!empty($candidates) && microtime(true) < $deadline) {
|
||||
$write = array_values($candidates);
|
||||
$except = array_values($candidates);
|
||||
$read = null;
|
||||
$usec = (int)(max(0, $deadline - microtime(true)) * 1_000_000);
|
||||
$n = @stream_select($read, $write, $except, 0, $usec);
|
||||
if ($n === false || $n === 0) break;
|
||||
|
||||
// Sockets in $except = connection refused/error
|
||||
$failed = [];
|
||||
foreach ($except as $s) {
|
||||
$ip = array_search($s, $candidates, true);
|
||||
if ($ip !== false) $failed[$ip] = true;
|
||||
}
|
||||
// Sockets in $write = connection complete (may overlap with $except on error)
|
||||
foreach ($write as $s) {
|
||||
$ip = array_search($s, $candidates, true);
|
||||
if ($ip === false) continue;
|
||||
if (!isset($failed[$ip])) {
|
||||
$found_tcp[] = $ip;
|
||||
}
|
||||
@fclose($s);
|
||||
unset($candidates[$ip]);
|
||||
}
|
||||
// Close failed sockets too
|
||||
foreach ($failed as $ip => $_) {
|
||||
if (isset($candidates[$ip])) {
|
||||
@fclose($candidates[$ip]);
|
||||
unset($candidates[$ip]);
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($candidates as $s) @fclose($s); // close remaining (timeout)
|
||||
|
||||
// ── Phase 2: WebSocket handshake to confirm each TCP responder ─────────────
|
||||
$gateways = [];
|
||||
foreach ($found_tcp as $ip) {
|
||||
$sock = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 2);
|
||||
if (!$sock) continue;
|
||||
stream_set_timeout($sock, 2);
|
||||
|
||||
$key = base64_encode(random_bytes(16));
|
||||
fwrite($sock,
|
||||
"GET / HTTP/1.1\r\n" .
|
||||
"Host: {$ip}:{$port}\r\n" .
|
||||
"Upgrade: websocket\r\n" .
|
||||
"Connection: Upgrade\r\n" .
|
||||
"Sec-WebSocket-Key: {$key}\r\n" .
|
||||
"Sec-WebSocket-Version: 13\r\n" .
|
||||
"\r\n"
|
||||
);
|
||||
|
||||
$resp = '';
|
||||
$dl = microtime(true) + 2;
|
||||
while (microtime(true) < $dl && !feof($sock)) {
|
||||
$line = fgets($sock, 256);
|
||||
if ($line === false) break;
|
||||
$resp .= $line;
|
||||
if ($line === "\r\n") break;
|
||||
}
|
||||
fclose($sock);
|
||||
|
||||
if (str_contains($resp, '101')) {
|
||||
$gateways[] = "ws://{$ip}:{$port}";
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'found' => $gateways,
|
||||
'subnet' => rtrim($subnet, '.') . '.0/24',
|
||||
'server_ip' => $serverIp,
|
||||
]);
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf Scale Gateway — Connection ping / test
|
||||
*
|
||||
* Performs a WebSocket handshake with the gateway and returns
|
||||
* {"ok":true} on success, {"ok":false,"error":"..."} on failure.
|
||||
*
|
||||
* Usage: GET /api/scale_ping.php?url=ws%3A%2F%2F192.168.1.100%3A8765
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache');
|
||||
|
||||
$rawUrl = $_GET['url'] ?? '';
|
||||
|
||||
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid gateway URL (must start with ws://)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$parsed = parse_url($rawUrl);
|
||||
$host = $parsed['host'] ?? '';
|
||||
$port = (int)($parsed['port'] ?? 8765);
|
||||
$path = ($parsed['path'] ?? '') ?: '/';
|
||||
|
||||
if (!$host || $port < 1 || $port > 65535) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid host or port']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Try to open a TCP connection with a 5-second timeout
|
||||
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
|
||||
if (!$sock) {
|
||||
echo json_encode(['ok' => false, 'error' => "Cannot connect to {$host}:{$port} — {$errstr}"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
stream_set_timeout($sock, 5);
|
||||
|
||||
// Perform WebSocket handshake
|
||||
$wsKey = base64_encode(random_bytes(16));
|
||||
fwrite($sock,
|
||||
"GET {$path} HTTP/1.1\r\n" .
|
||||
"Host: {$host}:{$port}\r\n" .
|
||||
"Upgrade: websocket\r\n" .
|
||||
"Connection: Upgrade\r\n" .
|
||||
"Sec-WebSocket-Key: {$wsKey}\r\n" .
|
||||
"Sec-WebSocket-Version: 13\r\n" .
|
||||
"\r\n"
|
||||
);
|
||||
|
||||
// Read HTTP response (looking for 101 Switching Protocols)
|
||||
$resp = '';
|
||||
while (!feof($sock)) {
|
||||
$line = fgets($sock, 1024);
|
||||
if ($line === false) break;
|
||||
$resp .= $line;
|
||||
if ($line === "\r\n") break;
|
||||
}
|
||||
|
||||
fclose($sock);
|
||||
|
||||
if (str_contains($resp, '101')) {
|
||||
echo json_encode(['ok' => true]);
|
||||
} else {
|
||||
echo json_encode(['ok' => false, 'error' => 'WebSocket handshake failed — check that the gateway app is running']);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf Scale Gateway — SSE Relay
|
||||
*
|
||||
* Bridges the Android BLE gateway (ws://) to Server-Sent Events (SSE) so that
|
||||
* browsers on HTTPS pages can receive weight data without mixed-content errors.
|
||||
*
|
||||
* Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765
|
||||
*/
|
||||
|
||||
// ── Input validation ──────────────────────────────────────────────────────────
|
||||
$rawUrl = $_GET['url'] ?? '';
|
||||
|
||||
// Only accept ws:// scheme with valid host and optional port/path
|
||||
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
||||
header('Content-Type: text/event-stream');
|
||||
echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Invalid gateway URL (must start with ws://)']) . "\n\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$parsed = parse_url($rawUrl);
|
||||
$wsHost = $parsed['host'] ?? '';
|
||||
$wsPort = (int)($parsed['port'] ?? 8765);
|
||||
$wsPath = ($parsed['path'] ?? '') ?: '/';
|
||||
|
||||
if (!$wsHost || $wsPort < 1 || $wsPort > 65535) {
|
||||
header('Content-Type: text/event-stream');
|
||||
echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Invalid host or port']) . "\n\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── SSE headers ───────────────────────────────────────────────────────────────
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
header('X-Accel-Buffering: no'); // Disable nginx / Caddy buffering
|
||||
header('Content-Encoding: identity'); // Prevent gzip/deflate compression
|
||||
|
||||
set_time_limit(0);
|
||||
ignore_user_abort(false); // stop when browser closes connection
|
||||
|
||||
// Clear all PHP output-buffering levels so echo/flush goes straight to SAPI
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
// ── Connect to Android gateway ────────────────────────────────────────────────
|
||||
$sock = @stream_socket_client("tcp://{$wsHost}:{$wsPort}", $errno, $errstr, 5);
|
||||
if (!$sock) {
|
||||
echo 'data: ' . json_encode([
|
||||
'type' => 'error',
|
||||
'message' => "Cannot connect to gateway ({$wsHost}:{$wsPort}): {$errstr}",
|
||||
]) . "\n\n";
|
||||
flush();
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── WebSocket handshake (PHP acts as client) ──────────────────────────────────
|
||||
// RFC 6455: client frames MUST be masked.
|
||||
$wsKey = base64_encode(random_bytes(16));
|
||||
|
||||
stream_set_blocking($sock, true);
|
||||
stream_set_timeout($sock, 5);
|
||||
|
||||
fwrite($sock,
|
||||
"GET {$wsPath} HTTP/1.1\r\n" .
|
||||
"Host: {$wsHost}:{$wsPort}\r\n" .
|
||||
"Upgrade: websocket\r\n" .
|
||||
"Connection: Upgrade\r\n" .
|
||||
"Sec-WebSocket-Key: {$wsKey}\r\n" .
|
||||
"Sec-WebSocket-Version: 13\r\n" .
|
||||
"\r\n"
|
||||
);
|
||||
|
||||
// Read HTTP 101 Switching Protocols response
|
||||
$httpResp = '';
|
||||
$deadline = microtime(true) + 5;
|
||||
while (microtime(true) < $deadline && !feof($sock)) {
|
||||
$line = fgets($sock, 1024);
|
||||
if ($line === false) break;
|
||||
$httpResp .= $line;
|
||||
if ($line === "\r\n") break;
|
||||
}
|
||||
|
||||
if (!str_contains($httpResp, '101')) {
|
||||
fclose($sock);
|
||||
echo 'data: ' . json_encode([
|
||||
'type' => 'error',
|
||||
'message' => 'WebSocket handshake failed — is the gateway URL correct?',
|
||||
]) . "\n\n";
|
||||
flush();
|
||||
exit;
|
||||
}
|
||||
|
||||
// Switch to non-blocking for poll-based relay loop
|
||||
stream_set_blocking($sock, false);
|
||||
stream_set_timeout($sock, 0);
|
||||
|
||||
// Ask gateway for current status immediately
|
||||
wsSend($sock, json_encode(['type' => 'get_status']));
|
||||
|
||||
// ── SSE relay loop ────────────────────────────────────────────────────────────
|
||||
$buf = '';
|
||||
$lastPing = time();
|
||||
|
||||
while (!connection_aborted()) {
|
||||
$chunk = @fread($sock, 8192);
|
||||
|
||||
if ($chunk === false || (feof($sock) && $chunk === '')) {
|
||||
// Gateway closed the connection
|
||||
echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Gateway disconnected']) . "\n\n";
|
||||
flush();
|
||||
break;
|
||||
}
|
||||
|
||||
if ($chunk !== '') {
|
||||
$buf .= $chunk;
|
||||
while (($payload = wsRead($buf, $sock)) !== null) {
|
||||
if ($payload === false) goto done; // close frame received
|
||||
if ($payload === '') continue; // ping/pong control frames (handled inside wsRead)
|
||||
echo "data: {$payload}\n\n";
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
// SSE keep-alive comment + gateway WebSocket ping every 20 seconds
|
||||
if (time() - $lastPing >= 20) {
|
||||
echo ": keep-alive\n\n";
|
||||
flush();
|
||||
$lastPing = time();
|
||||
wsPing($sock);
|
||||
}
|
||||
|
||||
usleep(100_000); // 100 ms polling interval
|
||||
}
|
||||
|
||||
done:
|
||||
fclose($sock);
|
||||
|
||||
// ── WebSocket helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Send a masked text frame from PHP (client) to the gateway (server). */
|
||||
function wsSend($sock, string $payload): void
|
||||
{
|
||||
$len = strlen($payload);
|
||||
$mask = random_bytes(4);
|
||||
|
||||
if ($len < 126) {
|
||||
$header = "\x81" . chr(0x80 | $len);
|
||||
} elseif ($len < 65536) {
|
||||
$header = "\x81\xFE" . pack('n', $len); // opcode=text, mask bit, 2-byte length
|
||||
} else {
|
||||
$header = "\x81\xFF" . pack('J', $len); // opcode=text, mask bit, 8-byte length
|
||||
}
|
||||
|
||||
$masked = '';
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$masked .= $payload[$i] ^ $mask[$i % 4];
|
||||
}
|
||||
|
||||
@fwrite($sock, $header . $mask . $masked);
|
||||
}
|
||||
|
||||
/** Send a masked ping to keep the gateway connection alive. */
|
||||
function wsPing($sock): void
|
||||
{
|
||||
@fwrite($sock, "\x89\x80" . random_bytes(4)); // FIN+ping, MASK bit, 4-byte mask, no payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to decode one complete WebSocket frame from the read buffer.
|
||||
* The gateway (server) sends unmasked frames to PHP (client).
|
||||
*
|
||||
* Returns:
|
||||
* string — decoded text/binary payload
|
||||
* '' — control frame (ping/pong) handled internally, skip
|
||||
* false — close frame received
|
||||
* null — not enough data yet, read more
|
||||
*/
|
||||
function wsRead(string &$buf, $sock): string|null|false
|
||||
{
|
||||
if (strlen($buf) < 2) return null;
|
||||
|
||||
$b0 = ord($buf[0]);
|
||||
$b1 = ord($buf[1]);
|
||||
$op = $b0 & 0x0F;
|
||||
$msk = ($b1 & 0x80) !== 0; // servers never mask, but handle defensively
|
||||
$len = $b1 & 0x7F;
|
||||
$off = 2;
|
||||
|
||||
if ($len === 126) {
|
||||
if (strlen($buf) < 4) return null;
|
||||
$len = unpack('n', substr($buf, 2, 2))[1];
|
||||
$off = 4;
|
||||
} elseif ($len === 127) {
|
||||
if (strlen($buf) < 10) return null;
|
||||
$len = unpack('J', substr($buf, 2, 8))[1];
|
||||
$off = 10;
|
||||
}
|
||||
|
||||
if ($msk) $off += 4;
|
||||
if (strlen($buf) < $off + $len) return null;
|
||||
|
||||
$maskKey = $msk ? substr($buf, $off - 4, 4) : null;
|
||||
$payload = substr($buf, $off, $len);
|
||||
|
||||
if ($msk && $maskKey) {
|
||||
for ($i = 0; $i < strlen($payload); $i++) {
|
||||
$payload[$i] = $payload[$i] ^ $maskKey[$i % 4];
|
||||
}
|
||||
}
|
||||
|
||||
// Consume frame from buffer
|
||||
$buf = substr($buf, $off + $len);
|
||||
|
||||
if ($op === 0x8) return false; // close frame
|
||||
if ($op === 0x9) { // ping → send masked pong
|
||||
$pLen = strlen($payload);
|
||||
$mask = random_bytes(4);
|
||||
$maskedPayload = '';
|
||||
for ($i = 0; $i < $pLen; $i++) {
|
||||
$maskedPayload .= $payload[$i] ^ $mask[$i % 4];
|
||||
}
|
||||
@fwrite($sock, "\x8A" . chr(0x80 | $pLen) . $mask . $maskedPayload);
|
||||
return '';
|
||||
}
|
||||
if ($op === 0xA) return ''; // pong — ignore
|
||||
|
||||
return $payload; // 0x1 = text, 0x2 = binary
|
||||
}
|
||||
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 115 KiB |
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"226887def70e33ef73290ebfe75ed4d0": {
|
||||
"days": 7,
|
||||
"source": "ai",
|
||||
"name": "Polpa di pomodoro finissima",
|
||||
"location": "frigo",
|
||||
"ts": 1777444819
|
||||
},
|
||||
"0ed51c9496aa9edfe38caf41772f54ed": {
|
||||
"days": 7,
|
||||
"source": "rule",
|
||||
"name": "Latte di Montagna",
|
||||
"location": "frigo",
|
||||
"ts": 1777444820
|
||||
},
|
||||
"2d63d0216a75d46b465150e925d2e7ad": {
|
||||
"days": 30,
|
||||
"source": "rule",
|
||||
"name": "Burro",
|
||||
"location": "frigo",
|
||||
"ts": 1777444821
|
||||
},
|
||||
"9afdf35c4a256867ef47c32495349eb6": {
|
||||
"days": 5,
|
||||
"source": "rule",
|
||||
"name": "Yaourt Vanille",
|
||||
"location": "frigo",
|
||||
"ts": 1777480477
|
||||
},
|
||||
"584f57418733a1f2acd29fe2e8816129": {
|
||||
"days": 5,
|
||||
"source": "rule",
|
||||
"name": "Passata di pomodoro",
|
||||
"location": "frigo",
|
||||
"ts": 1778133522
|
||||
},
|
||||
"baeb7f2021b4bb91c368c9131a61f07c": {
|
||||
"days": 10,
|
||||
"source": "rule",
|
||||
"name": "Formaggio Monte Maria",
|
||||
"location": "frigo",
|
||||
"ts": 1778133523
|
||||
},
|
||||
"063f2d534407214786d039bb2bffbb93": {
|
||||
"days": 5,
|
||||
"source": "rule",
|
||||
"name": "Carote",
|
||||
"location": "frigo",
|
||||
"ts": 1778133524
|
||||
},
|
||||
"10a3d07c19bb1f889ebc9293862b4b36": {
|
||||
"days": 60,
|
||||
"source": "rule",
|
||||
"name": "Ovomaltine",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419084
|
||||
},
|
||||
"0fbad7ccd8b6155c06aaa6b3c17a67d3": {
|
||||
"days": 365,
|
||||
"source": "rule",
|
||||
"name": "Linguine pasta di Gragnano Igp",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419084
|
||||
},
|
||||
"b4a03e7356e7a0983b9c8af5f3cd8c57": {
|
||||
"days": 60,
|
||||
"source": "rule",
|
||||
"name": "Polpa di pomodoro finissima",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419085
|
||||
},
|
||||
"b8334ff0febd5c0440c9b24c9f3132ed": {
|
||||
"days": 180,
|
||||
"source": "rule",
|
||||
"name": "Basilico tritato surgelato",
|
||||
"location": "freezer",
|
||||
"ts": 1778419086
|
||||
},
|
||||
"0cb14384d0ba763ccf12e079d6aa8d34": {
|
||||
"days": 60,
|
||||
"source": "rule",
|
||||
"name": "Salsa Pronta Ciliegini",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419086
|
||||
},
|
||||
"188634f49edb8b014a46942ee9fad689": {
|
||||
"days": 180,
|
||||
"source": "rule",
|
||||
"name": "Farina Barilla",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419204
|
||||
},
|
||||
"c8db359d8709c69a95f0e6f68216d220": {
|
||||
"days": 9999,
|
||||
"source": "rule",
|
||||
"name": "Bicarbonato",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419205
|
||||
},
|
||||
"a6d16a09fd9a6bfbd0a915f05dd71780": {
|
||||
"days": 7,
|
||||
"source": "ai",
|
||||
"name": "Salsa Pronta Ciliegini",
|
||||
"location": "frigo",
|
||||
"ts": 1778419205
|
||||
},
|
||||
"4f8f1bb04a00e5fc62d7a9cfb21e1796": {
|
||||
"days": 365,
|
||||
"source": "rule",
|
||||
"name": "Riso Chicchi Ricchi Gran Risparmio",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419206
|
||||
},
|
||||
"e116e4c11084a463f9aaac02e1749fe7": {
|
||||
"days": 90,
|
||||
"source": "rule",
|
||||
"name": "Salsa di soia",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419207
|
||||
},
|
||||
"b1ad9afd4139b3f225b79af4dae256ce": {
|
||||
"days": 60,
|
||||
"source": "rule",
|
||||
"name": "Tè Al limone",
|
||||
"location": "dispensa",
|
||||
"ts": 1778419504
|
||||
},
|
||||
"7ff2b7d326dcba52a664cebbf12f78a2": {
|
||||
"days": 3,
|
||||
"source": "ai",
|
||||
"name": "Piselli fini 1\/2 vapore",
|
||||
"location": "frigo",
|
||||
"ts": 1778419505
|
||||
},
|
||||
"71062dc7ffd82b3ee4f40bad076a7c91": {
|
||||
"days": 60,
|
||||
"source": "rule",
|
||||
"name": "Cioccolato bianco",
|
||||
"location": "frigo",
|
||||
"ts": 1778419506
|
||||
},
|
||||
"38a0eaea422dfe970eba125494e75981": {
|
||||
"days": 180,
|
||||
"source": "rule",
|
||||
"name": "Zucca a pezzi",
|
||||
"location": "freezer",
|
||||
"ts": 1778419506
|
||||
},
|
||||
"cde21270e1cd50c431742e49117b225d": {
|
||||
"days": 7,
|
||||
"source": "rule",
|
||||
"name": "Pancetta Dolce",
|
||||
"location": "frigo",
|
||||
"ts": 1778419507
|
||||
},
|
||||
"9e4189bd3f8cb1121e7389967dd4f74c": {
|
||||
"days": 180,
|
||||
"source": "rule",
|
||||
"name": "Farina di grano tenero tipo rossa",
|
||||
"location": "dispensa",
|
||||
"ts": 1778427005
|
||||
},
|
||||
"e3472dd051ed13ae18fc96bbebedc1ba": {
|
||||
"days": 60,
|
||||
"source": "rule",
|
||||
"name": "Lievito di birra",
|
||||
"location": "dispensa",
|
||||
"ts": 1778427005
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"dc1bb00e006a5ed073aad9b0ca2f1601": "Toast",
|
||||
"f03b656f4cfaa9d633fc155cdafcb83b": "Sale",
|
||||
"fa1266e5e6bb32602e08aaf9434ec9ad": "Patate",
|
||||
"ca2da3ad2a7b42e717f766e06a83730e": "Verdure",
|
||||
"ce8f4f54fc6ead0f0a8ce36503bba462": "Pasta",
|
||||
"2ddb0faf33c4ceeed89fada2c7c2b9c5": "Ingredienti Spezie",
|
||||
"0290647fcd95ec97f0d6666c46a72943": "Brodo",
|
||||
"405ea6ec33d54042d046599650f422ea": "Succo",
|
||||
"f624c420f14d8eff122c0bb395eb63da": "Snack Dolci",
|
||||
"92751fbb97923590c402bc7810778b36": "Biscotti",
|
||||
"8727f7abcb66764b5eb3d1f036bc18b8": "Tè",
|
||||
"0eb53fe1a5d4d106eac47c8a81d1afe7": "Farina",
|
||||
"0ebada5597d1d166d0ed8f49500bfeba": "Verdure",
|
||||
"fe7456efb7e767a06e3af9f5ec7b3637": "Piatti Pronti",
|
||||
"2a5d2289bb7bc306dd066dfaff7ef581": "Ingredienti Spezie",
|
||||
"b630c06f2ac72a1e2ffbd57d327a3733": "Salsa",
|
||||
"32a05ae91ccfa4d37be454836971436b": "Ingredienti",
|
||||
"a21f0e7718c8f12166d864d0d05f60a0": "Salsa"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -500,39 +500,6 @@ paths:
|
||||
"200":
|
||||
description: Recipe deleted
|
||||
|
||||
/index.php?action=dupliclick_login:
|
||||
post:
|
||||
summary: Login to DupliClick (online shopping)
|
||||
tags: [DupliClick]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Login successful
|
||||
|
||||
/index.php?action=dupliclick_search:
|
||||
get:
|
||||
summary: Search DupliClick product catalog
|
||||
tags: [DupliClick]
|
||||
parameters:
|
||||
- name: q
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Search results
|
||||
|
||||
/index.php?action=tts_proxy:
|
||||
post:
|
||||
summary: Proxy TTS request to external endpoint
|
||||
@@ -685,7 +652,5 @@ tags:
|
||||
description: Recipe storage
|
||||
- name: Settings
|
||||
description: Application and server settings
|
||||
- name: DupliClick
|
||||
description: DupliClick online shopping integration
|
||||
- name: TTS
|
||||
description: Text-to-Speech proxy
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
# 🔌 API Reference
|
||||
|
||||
EverShelf exposes a single PHP endpoint: **`api/index.php`**. All actions are selected via the `action` query parameter.
|
||||
|
||||
> **Full OpenAPI 3.1 spec:** [`docs/openapi.yaml`](https://github.com/dadaloop82/EverShelf/blob/main/docs/openapi.yaml)
|
||||
|
||||
---
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://your-server/api/index.php?action=ACTION_NAME
|
||||
```
|
||||
|
||||
GET requests pass parameters as query params; POST requests send JSON in the body.
|
||||
|
||||
---
|
||||
|
||||
## Rate Limits
|
||||
|
||||
| Tier | Limit | Applies to |
|
||||
|------|-------|-----------|
|
||||
| Standard | 120 req/min | All general endpoints |
|
||||
| AI | 15 req/min | `gemini_*`, `generate_recipe*` |
|
||||
| Strict | 5 req/min | `report_error` |
|
||||
|
||||
Exceeded limits return HTTP 429 with `{"error": "rate_limit_exceeded"}`.
|
||||
|
||||
---
|
||||
|
||||
## Products
|
||||
|
||||
### `search_barcode` — GET
|
||||
Search for a product in the local database by barcode.
|
||||
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `barcode` | string | EAN/UPC barcode |
|
||||
|
||||
### `lookup_barcode` — GET
|
||||
Look up a barcode on Open Food Facts (external call).
|
||||
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `barcode` | string | EAN/UPC barcode |
|
||||
|
||||
### `product_save` — POST
|
||||
Create or update a product. Pass `id` to update.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"name": "Pasta Barilla",
|
||||
"brand": "Barilla",
|
||||
"category": "pasta",
|
||||
"unit": "g",
|
||||
"default_quantity": 500,
|
||||
"barcode": "8076800105988"
|
||||
}
|
||||
```
|
||||
|
||||
### `product_get` — GET
|
||||
Get product details by `id`.
|
||||
|
||||
### `product_delete` — POST
|
||||
Delete a product by `id`.
|
||||
|
||||
### `products_list` — GET
|
||||
List all products.
|
||||
|
||||
### `products_search` — GET
|
||||
Search products by name (`?q=pasta`).
|
||||
|
||||
---
|
||||
|
||||
## Inventory
|
||||
|
||||
### `inventory_list` — GET
|
||||
List all inventory items with product details, grouped.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"inventory": [
|
||||
{
|
||||
"id": 1,
|
||||
"product_id": 42,
|
||||
"name": "Pasta Barilla",
|
||||
"quantity": 2,
|
||||
"unit": "pz",
|
||||
"location": "dispensa",
|
||||
"expiry_date": "2027-03-01",
|
||||
"opened_at": null,
|
||||
"vacuum_sealed": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `inventory_add` — POST
|
||||
Add a product to inventory.
|
||||
|
||||
```json
|
||||
{
|
||||
"product_id": 42,
|
||||
"quantity": 3,
|
||||
"location": "dispensa",
|
||||
"expiry_date": "2027-03-01",
|
||||
"vacuum_sealed": false
|
||||
}
|
||||
```
|
||||
|
||||
**Locations:** `dispensa`, `frigo`, `freezer`, `altro`
|
||||
|
||||
### `inventory_use` — POST
|
||||
Consume inventory. Set `use_all: true` to consume all stock at a location.
|
||||
|
||||
```json
|
||||
{
|
||||
"product_id": 42,
|
||||
"quantity": 1,
|
||||
"location": "dispensa"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"product_id": 42,
|
||||
"use_all": true,
|
||||
"location": "__all__",
|
||||
"notes": "Buttato"
|
||||
}
|
||||
```
|
||||
|
||||
### `inventory_update` — POST
|
||||
Update an inventory entry by `id`.
|
||||
|
||||
### `inventory_delete` — POST
|
||||
Remove an inventory entry by `id`.
|
||||
|
||||
### `inventory_summary` — GET
|
||||
Returns item counts per location.
|
||||
|
||||
```json
|
||||
{
|
||||
"dispensa": 12,
|
||||
"frigo": 5,
|
||||
"freezer": 8
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Transactions (Log)
|
||||
|
||||
### `transactions_list` — GET
|
||||
Returns the operation log.
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `limit` | int | 50 | Results per page |
|
||||
| `offset` | int | 0 | Pagination offset |
|
||||
|
||||
### `transaction_undo` — POST
|
||||
Undo a transaction within 24 hours.
|
||||
|
||||
```json
|
||||
{ "id": 873 }
|
||||
```
|
||||
|
||||
**Response on success:**
|
||||
```json
|
||||
{ "success": true, "name": "Tonno all'olio d'oliva" }
|
||||
```
|
||||
|
||||
**Error cases:**
|
||||
```json
|
||||
{ "error": "...", "already_undone": true }
|
||||
{ "error": "...", "too_old": true }
|
||||
```
|
||||
|
||||
### `stats` — GET
|
||||
Returns waste and consumption statistics for the last 30 days.
|
||||
|
||||
---
|
||||
|
||||
## AI / Gemini
|
||||
|
||||
All AI endpoints require `GEMINI_API_KEY` to be configured. Rate limit: 15 req/min.
|
||||
|
||||
### `gemini_expiry` — POST
|
||||
Read an expiry date from a product photo.
|
||||
|
||||
```json
|
||||
{ "image": "data:image/jpeg;base64,..." }
|
||||
```
|
||||
|
||||
### `gemini_identify` — POST
|
||||
Identify a product from a photo.
|
||||
|
||||
```json
|
||||
{ "image": "data:image/jpeg;base64,..." }
|
||||
```
|
||||
|
||||
### `gemini_chat` — POST
|
||||
Chat with the AI kitchen assistant.
|
||||
|
||||
```json
|
||||
{ "message": "Cosa posso fare con la pasta?", "history": [] }
|
||||
```
|
||||
|
||||
### `generate_recipe` — POST
|
||||
Generate a recipe based on current inventory.
|
||||
|
||||
```json
|
||||
{ "persons": 2, "meal": "dinner", "preferences": {} }
|
||||
```
|
||||
|
||||
### `generate_recipe_stream` — POST
|
||||
Same as `generate_recipe` but streams output via Server-Sent Events.
|
||||
|
||||
### `gemini_product_hint` — POST
|
||||
Get AI storage location + shelf-life hint for a new product.
|
||||
|
||||
### `gemini_shopping_enrich` — POST
|
||||
Enrich shopping suggestions with practical tips.
|
||||
|
||||
### `gemini_anomaly_explain` — POST
|
||||
Get a plain-language explanation for a specific inventory anomaly.
|
||||
|
||||
---
|
||||
|
||||
## Shopping List (Bring!)
|
||||
|
||||
Requires `BRING_EMAIL` and `BRING_PASSWORD` in `.env`.
|
||||
|
||||
### `bring_list` — GET
|
||||
Get the current Bring! shopping list.
|
||||
|
||||
### `bring_add` — POST
|
||||
Add items to the Bring! list.
|
||||
|
||||
```json
|
||||
{ "items": ["Latte", "Pane"] }
|
||||
```
|
||||
|
||||
### `bring_remove` — POST
|
||||
Remove an item from the Bring! list.
|
||||
|
||||
```json
|
||||
{ "name": "Latte" }
|
||||
```
|
||||
|
||||
### `smart_shopping` — GET
|
||||
Get smart shopping predictions based on consumption history.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
### `get_settings` — GET
|
||||
Returns current settings as **boolean flags only** (no raw key values):
|
||||
|
||||
```json
|
||||
{
|
||||
"gemini_key_set": true,
|
||||
"bring_configured": false,
|
||||
"tts_enabled": false,
|
||||
"scale_enabled": true,
|
||||
"demo_mode": false,
|
||||
"settings_token_set": true
|
||||
}
|
||||
```
|
||||
|
||||
### `save_settings` — POST
|
||||
Update server configuration. If `SETTINGS_TOKEN` is set, requires header:
|
||||
|
||||
```
|
||||
X-Settings-Token: your_token
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"gemini_api_key": "...",
|
||||
"bring_email": "...",
|
||||
"scale_enabled": true,
|
||||
"scale_gateway_url": "ws://127.0.0.1:8765"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Reporting
|
||||
|
||||
### `report_error` — POST
|
||||
Submit an automatic error report (creates a GitHub Issue).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "uncaught-error",
|
||||
"message": "...",
|
||||
"stack": "...",
|
||||
"context": {}
|
||||
}
|
||||
```
|
||||
|
||||
Only creates an issue if:
|
||||
- The client is running the latest released version
|
||||
- The fingerprint hasn't been seen in the last 24 hours
|
||||
|
||||
---
|
||||
|
||||
## Anomaly Detection
|
||||
|
||||
### `inventory_anomalies` — GET
|
||||
Returns inventory rows where stored quantity significantly differs from transaction history.
|
||||
|
||||
### `dismiss_anomaly` — POST
|
||||
Dismiss an anomaly banner without changing inventory.
|
||||
|
||||
---
|
||||
|
||||
## Scale Integration
|
||||
|
||||
### `scale_relay` (SSE) — GET
|
||||
Relays BLE scale readings from the gateway to the browser via Server-Sent Events (avoids HTTPS→WS mixed-content issues).
|
||||
|
||||
### `scale_ping` — GET
|
||||
Check if the Scale Gateway is reachable.
|
||||
|
||||
### `scale_discover` — GET
|
||||
Scan the local LAN for a running Scale Gateway instance.
|
||||
@@ -0,0 +1,141 @@
|
||||
# 📺 Android Kiosk App
|
||||
|
||||
The EverShelf Kiosk app turns any Android tablet into a dedicated, locked-down kitchen display running EverShelf full-screen.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
**[⬇ Download latest APK](https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-kiosk.apk)**
|
||||
|
||||
> Current version: **v1.5.0** — requires Android 7.0+
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
- Displays the EverShelf web app in a **full-screen WebView** (no browser chrome)
|
||||
- **Locks the screen** with Android's `startLockTask` — home, recents, and back buttons are blocked
|
||||
- Runs the **Scale Gateway** app in the background automatically on startup
|
||||
- Provides a **native TTS bridge** so Cooking Mode reads steps aloud via Android TextToSpeech
|
||||
- Auto-detects your EverShelf server on the LAN with a **smart discovery scanner**
|
||||
- Reports errors and install failures back to the developer automatically
|
||||
|
||||
---
|
||||
|
||||
## Setup Wizard (6 steps)
|
||||
|
||||
The wizard runs automatically on first launch.
|
||||
|
||||
### Step 1 — Language
|
||||
Select the app and web interface language (Italian, English, German).
|
||||
|
||||
### Step 2 — Welcome
|
||||
Overview of what the wizard will configure.
|
||||
|
||||
### Step 3 — Permissions
|
||||
Grant camera, microphone, and storage permissions needed by the web app.
|
||||
|
||||
The button transforms from "Concedi permessi" to **"✅ Permessi concessi — Continua →"** (green) once all permissions are granted.
|
||||
|
||||
### Step 4 — Server URL
|
||||
Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`).
|
||||
|
||||
**Or tap "Rileva automaticamente"** to let the wizard scan your LAN:
|
||||
- 60 parallel threads, TCP pre-check, ports 80/443/8080/8443
|
||||
- Only scans your actual Wi-Fi/Ethernet subnet (VPN and cellular interfaces ignored)
|
||||
- Real-time feedback as hosts are tested
|
||||
|
||||
### Step 5 — Scale Gateway
|
||||
If you have a BLE smart scale, install and configure the Scale Gateway:
|
||||
1. Tap **"Installa Gateway"** — the APK is downloaded from GitHub and installed via `PackageInstaller`
|
||||
2. If installation fails, a diagnostic dialog shows: status code, error message, APK size, Android version, and device model — plus a "Riprova" button
|
||||
3. On success, the wizard automatically writes `scale_enabled=true` and `scale_gateway_url=ws://127.0.0.1:8765` to your EverShelf server
|
||||
|
||||
### Step 6 — Screensaver
|
||||
Choose whether the screen should go dark after inactivity.
|
||||
|
||||
### Summary
|
||||
All done — the web app loads in full-screen kiosk mode.
|
||||
|
||||
---
|
||||
|
||||
## Exiting Kiosk Mode
|
||||
|
||||
Tap the **✕** button in the header (top-left). A confirmation dialog appears.
|
||||
|
||||
---
|
||||
|
||||
## Hard Refresh
|
||||
|
||||
Tap the **↻** button in the header to clear the WebView cache and reload the latest version of the web app.
|
||||
|
||||
---
|
||||
|
||||
## Update Notifications
|
||||
|
||||
Every 6 hours the app checks GitHub releases. If a newer version is available, a banner appears with a one-tap download and install flow.
|
||||
|
||||
---
|
||||
|
||||
## Native TTS Bridge
|
||||
|
||||
When Cooking Mode reads recipe steps, the kiosk app:
|
||||
1. Intercepts the TTS call from the web app via a JavaScript bridge
|
||||
2. Uses the Android `TextToSpeech` engine directly
|
||||
3. Falls back to the browser Web Speech API if the bridge is unavailable
|
||||
|
||||
No internet connection required for TTS. No extra voice packs to install.
|
||||
|
||||
---
|
||||
|
||||
## SSL / Self-signed Certificates
|
||||
|
||||
The WebView accepts self-signed certificates automatically. No configuration needed for local HTTPS servers.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Impossibile installare il gateway"
|
||||
- Make sure "Install from unknown sources" is enabled for the kiosk app in Android Settings → Apps → Special app access
|
||||
- Check that there is enough free storage (the APK is ~15 MB)
|
||||
- The diagnostic dialog shows the exact failure code — include it when opening an issue
|
||||
|
||||
### "Server non trovato" during auto-discovery
|
||||
- Make sure your tablet and server are on the same Wi-Fi network
|
||||
- Ensure the server is not on a VPN-only interface
|
||||
- Try entering the URL manually
|
||||
|
||||
### Screen pinning / back button not working
|
||||
- Screen pinning requires the app to be set as Device Owner or the user to confirm the pin prompt
|
||||
- Some Android skins (Samsung, Xiaomi) may require additional accessibility permissions
|
||||
|
||||
### App crashes on startup
|
||||
- Force-stop the app, clear its data (Settings → Apps → EverShelf Kiosk → Clear data), and relaunch
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
```bash
|
||||
cd evershelf-kiosk
|
||||
./gradlew assembleRelease
|
||||
# APK: app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
Requires Android Studio or JDK 17+ with the Android SDK.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Purpose |
|
||||
|-----------|---------|
|
||||
| `INTERNET` | Load the EverShelf web app |
|
||||
| `CAMERA` | Barcode scanning and AI photo identification |
|
||||
| `RECORD_AUDIO` | Voice input in AI chat |
|
||||
| `WAKE_LOCK` | Keep the screen on |
|
||||
| `REQUEST_INSTALL_PACKAGES` | Install the Scale Gateway APK |
|
||||
| `ACCESS_WIFI_STATE` | LAN auto-discovery |
|
||||
| `REORDER_TASKS` | Bring app to foreground after gateway launch |
|
||||
@@ -0,0 +1,157 @@
|
||||
# ⚙️ Configuration
|
||||
|
||||
EverShelf is configured via a `.env` file in the project root. Copy `.env.example` to `.env` and edit it — the app reads this file on every API call.
|
||||
|
||||
**Never commit `.env` to Git.** It is already in `.gitignore`.
|
||||
|
||||
---
|
||||
|
||||
## Full `.env` Reference
|
||||
|
||||
```ini
|
||||
# ─────────────────────────────────────────────
|
||||
# AI — Google Gemini
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# Your Gemini API key (required for all AI features)
|
||||
# Get one free at: https://aistudio.google.com/app/apikey
|
||||
GEMINI_API_KEY=
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Shopping List — Bring! Integration
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# Your Bring! account credentials
|
||||
# Leave blank to disable Bring! integration
|
||||
BRING_EMAIL=
|
||||
BRING_PASSWORD=
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Text-to-Speech (for Cooking Mode)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# URL to a TTS endpoint (e.g. Home Assistant event endpoint)
|
||||
TTS_URL=
|
||||
|
||||
# Bearer token for the TTS endpoint
|
||||
TTS_TOKEN=
|
||||
|
||||
# Set to true to enable server-side TTS (the browser Web Speech API is always used as fallback)
|
||||
TTS_ENABLED=false
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Security
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# Protect the save_settings endpoint with a token
|
||||
# If set, the Settings UI will prompt for this value before saving
|
||||
# Validated with hash_equals() to prevent timing attacks
|
||||
SETTINGS_TOKEN=
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Demo / Public Mode
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# Set to true to block ALL write operations at the PHP router level
|
||||
# Useful for public demos or read-only kiosk deployments
|
||||
# Also activatable per-request via ?demo=1 URL parameter
|
||||
DEMO_MODE=false
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Scale Gateway
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# Enable the BLE scale integration
|
||||
SCALE_ENABLED=false
|
||||
|
||||
# WebSocket URL of the Scale Gateway app running on the same device
|
||||
# Default for Android kiosk: ws://127.0.0.1:8765
|
||||
SCALE_GATEWAY_URL=ws://127.0.0.1:8765
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Settings UI
|
||||
|
||||
Most settings can also be configured from the browser via **Settings → ⚙️**:
|
||||
|
||||
| Setting | `.env` key | Notes |
|
||||
|---------|-----------|-------|
|
||||
| Gemini API key | `GEMINI_API_KEY` | Stored server-side, never exposed to browser |
|
||||
| Bring! email | `BRING_EMAIL` | — |
|
||||
| Bring! password | `BRING_PASSWORD` | — |
|
||||
| TTS URL | `TTS_URL` | — |
|
||||
| TTS token | `TTS_TOKEN` | — |
|
||||
| TTS enabled | `TTS_ENABLED` | — |
|
||||
| Scale enabled | `SCALE_ENABLED` | — |
|
||||
| Scale gateway URL | `SCALE_GATEWAY_URL` | — |
|
||||
| Settings token | `SETTINGS_TOKEN` | Write-only; current value never shown |
|
||||
|
||||
> **Security note:** `get_settings` returns only **boolean flags** (`gemini_key_set: true/false`), never raw key values. Raw values are only accessible server-side.
|
||||
|
||||
---
|
||||
|
||||
## Protecting Settings with a Token
|
||||
|
||||
If your EverShelf instance is accessible from untrusted networks, set `SETTINGS_TOKEN` to a strong random string:
|
||||
|
||||
```bash
|
||||
# Generate a strong token
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
```ini
|
||||
SETTINGS_TOKEN=a3f9b2c1d4e5...
|
||||
```
|
||||
|
||||
Users will be prompted for this token before any Settings save. If the token doesn't match, the request is rejected with HTTP 403.
|
||||
|
||||
---
|
||||
|
||||
## Demo Mode
|
||||
|
||||
Two ways to enable demo mode:
|
||||
|
||||
1. **Permanent:** Set `DEMO_MODE=true` in `.env`
|
||||
2. **Per-session:** Append `?demo=1` to any URL (e.g. `https://evershelfproject.dadaloop.it/demo`)
|
||||
|
||||
In demo mode:
|
||||
- All POST/write API calls return success without touching the database
|
||||
- A "DEMO" badge appears in the header
|
||||
- Gemini AI is treated as available (mock responses)
|
||||
- Bring! write operations are silently no-op'd
|
||||
- A mock pantry with sample data is loaded
|
||||
|
||||
---
|
||||
|
||||
## API Rate Limiting
|
||||
|
||||
EverShelf applies file-based rate limiting to protect AI endpoints:
|
||||
|
||||
| Tier | Limit | Endpoints |
|
||||
|------|-------|-----------|
|
||||
| Standard | 120 req/min | All general endpoints |
|
||||
| AI | 15 req/min | `gemini_*`, `generate_recipe` |
|
||||
| Strict | 5 req/min | `report_error` |
|
||||
|
||||
Rate limit state is stored in `data/rate_limits/`. To reset, delete the files in that directory.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
EverShelf uses **SQLite** stored at `data/evershelf.db`. The file is created automatically on first run.
|
||||
|
||||
Schema migrations run automatically whenever `database.php` is loaded — no manual migration steps needed.
|
||||
|
||||
To back up the database:
|
||||
|
||||
```bash
|
||||
cp data/evershelf.db data/backups/evershelf-$(date +%Y%m%d).db
|
||||
```
|
||||
|
||||
Or use the included `backup.sh`:
|
||||
|
||||
```bash
|
||||
./backup.sh
|
||||
```
|
||||
@@ -0,0 +1,164 @@
|
||||
# 🤝 Contributing
|
||||
|
||||
Contributions of all kinds are welcome — bug fixes, new features, translations, documentation improvements.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Fork and clone
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/EverShelf.git
|
||||
cd EverShelf
|
||||
```
|
||||
|
||||
### 2. Create a branch
|
||||
|
||||
```bash
|
||||
git checkout -b feature/my-feature
|
||||
# or
|
||||
git checkout -b fix/my-bug-fix
|
||||
```
|
||||
|
||||
### 3. Set up a local server
|
||||
|
||||
```bash
|
||||
# Option A: PHP built-in server
|
||||
php -S localhost:8080
|
||||
|
||||
# Option B: Docker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://localhost:8080` in your browser.
|
||||
|
||||
### 4. Make your changes
|
||||
|
||||
The app has **no build step**. Edit files directly and refresh the browser.
|
||||
|
||||
Key files:
|
||||
- `assets/js/app.js` — all frontend logic
|
||||
- `assets/css/style.css` — all styles
|
||||
- `api/index.php` — all API endpoints
|
||||
- `api/database.php` — SQLite schema and migrations
|
||||
- `translations/*.json` — i18n strings
|
||||
|
||||
### 5. Test
|
||||
|
||||
```bash
|
||||
# Check PHP syntax
|
||||
php -l api/index.php
|
||||
php -l api/database.php
|
||||
|
||||
# Check JS syntax
|
||||
node --check assets/js/app.js
|
||||
```
|
||||
|
||||
There are no automated JS tests yet — manual testing in the browser is the current approach. If you add a feature, test the full flow: add, use, undo.
|
||||
|
||||
### 6. Commit
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```bash
|
||||
git commit -m "feat(inventory): add bulk delete"
|
||||
git commit -m "fix(scale): handle BLE disconnect during countdown"
|
||||
git commit -m "docs: update kiosk setup guide"
|
||||
git commit -m "chore: bump version to 1.8.0"
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||
|
||||
Scopes: `inventory`, `ai`, `shopping`, `cooking`, `scale`, `kiosk`, `gateway`, `webapp`, `api`, `db`
|
||||
|
||||
### 7. Push and open a PR
|
||||
|
||||
```bash
|
||||
git push origin feature/my-feature
|
||||
```
|
||||
|
||||
Open a Pull Request against the `develop` branch (not `main`).
|
||||
|
||||
---
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
| Branch | Purpose |
|
||||
|--------|---------|
|
||||
| `main` | Production — auto-deployed, never commit directly |
|
||||
| `develop` | Integration branch — all PRs target here |
|
||||
| `feature/*` | New features |
|
||||
| `fix/*` | Bug fixes |
|
||||
|
||||
CI auto-merges `develop → main` on every push to `develop`.
|
||||
|
||||
---
|
||||
|
||||
## CI / CD Pipeline
|
||||
|
||||
GitHub Actions runs on every push to `develop` and `main`:
|
||||
|
||||
1. **PHP lint** — `php -l` on all PHP files
|
||||
2. **JS syntax check** — `node --check assets/js/app.js`
|
||||
3. **Translation validation** — checks that all language files have the same keys
|
||||
4. **Docker build** — verifies the Docker image builds successfully
|
||||
5. **Android build** — (on tagged commits) builds Kiosk and Scale Gateway APKs
|
||||
|
||||
---
|
||||
|
||||
## Adding Translations
|
||||
|
||||
See the full guide in [Translations](Translations).
|
||||
|
||||
Short version:
|
||||
1. Copy `translations/it.json` → `translations/xx.json`
|
||||
2. Translate all values
|
||||
3. Add `'xx'` to `SUPPORTED_LANGUAGES` in `app.js`
|
||||
4. Open a PR
|
||||
|
||||
---
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
Open an issue on GitHub. Include:
|
||||
- Steps to reproduce
|
||||
- Expected vs. actual behaviour
|
||||
- Browser/OS version
|
||||
- Any console errors (F12 → Console)
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
- **PHP:** PSR-12, 4-space indent, type hints where practical
|
||||
- **JavaScript:** ES2020+, `async/await`, no frameworks, 4-space indent
|
||||
- **CSS:** BEM-ish class names, CSS custom properties for theming
|
||||
- **SQL:** parameterized queries (PDO), no raw string interpolation
|
||||
|
||||
---
|
||||
|
||||
## Adding a New API Endpoint
|
||||
|
||||
1. Add a `case 'my_action':` to the router in `api/index.php`
|
||||
2. Implement `function myAction(PDO $db): void`
|
||||
3. Add the endpoint to `docs/openapi.yaml`
|
||||
4. Add translations for any new UI strings to all 3 language files
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
If you find a security vulnerability, **do not open a public issue**. Email [evershelfproject@gmail.com](mailto:evershelfproject@gmail.com) directly.
|
||||
|
||||
Relevant resources:
|
||||
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||
- All SQL must use PDO prepared statements
|
||||
- Never expose API keys in API responses (boolean flags only)
|
||||
- Use `hash_equals()` for token comparison
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
By contributing you agree that your code will be licensed under the [MIT License](https://github.com/dadaloop82/EverShelf/blob/main/LICENSE).
|
||||
@@ -0,0 +1,164 @@
|
||||
# ❓ FAQ & Troubleshooting
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### The app shows a blank page after setup
|
||||
|
||||
- Open the browser console (F12 → Console) and check for errors
|
||||
- Make sure PHP is running and `api/index.php` is reachable: visit `https://your-server/dispensa/api/index.php?action=get_settings` — it should return JSON
|
||||
- Check your web server error log: `tail -f /var/log/apache2/error.log`
|
||||
|
||||
### Camera doesn't work / barcode scanner won't open
|
||||
|
||||
Camera access requires **HTTPS**. On plain HTTP, browsers block `getUserMedia()`.
|
||||
|
||||
- Set up HTTPS with Let's Encrypt, Caddy, or a self-signed certificate
|
||||
- On Android, you can also add a security exception in Chrome: `chrome://flags/#allow-insecure-localhost`
|
||||
|
||||
### "Permission denied" error for the data directory
|
||||
|
||||
```bash
|
||||
chmod 755 data/
|
||||
chown -R www-data:www-data data/
|
||||
```
|
||||
|
||||
### Docker container exits immediately
|
||||
|
||||
```bash
|
||||
docker compose logs evershelf
|
||||
```
|
||||
|
||||
Usually a permission issue on the mounted `data/` volume. Try:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
rm -rf data/
|
||||
mkdir data/
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI Features
|
||||
|
||||
### AI features don't work / "AI non disponibile"
|
||||
|
||||
1. Check that `GEMINI_API_KEY` is set in `.env`
|
||||
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)
|
||||
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`:
|
||||
|
||||
```ini
|
||||
max_execution_time = 120
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shopping List (Bring!)
|
||||
|
||||
### "Bring! non configurato" message in the shopping tab
|
||||
|
||||
Add your Bring! credentials to `.env`:
|
||||
|
||||
```ini
|
||||
BRING_EMAIL=your@email.com
|
||||
BRING_PASSWORD=yourpassword
|
||||
```
|
||||
|
||||
### Items aren't syncing to Bring!
|
||||
|
||||
- Verify your credentials are correct by logging into [getbring.com](https://web.getbring.com/)
|
||||
- Check for rate-limit errors in the PHP error log — Bring! has API limits
|
||||
|
||||
---
|
||||
|
||||
## Scale Integration
|
||||
|
||||
### Scale readings don't appear in EverShelf
|
||||
|
||||
1. Confirm the gateway app is running and shows the WebSocket URL
|
||||
2. Check the Gateway URL in EverShelf Settings matches exactly
|
||||
3. Make sure both the Android device and the EverShelf server are on the same network
|
||||
4. Look at the scale status indicator (⚖️) in the header — "disconnected" means no WebSocket connection
|
||||
|
||||
### Scale shows weight but form doesn't auto-fill
|
||||
|
||||
- The auto-fill only triggers for products with unit `g` or `ml`
|
||||
- Make sure you tapped "⚖️ Leggi bilancia" first to activate the scale modal
|
||||
- The weight must stabilize (stay within 10g) for the countdown to start
|
||||
|
||||
### Bluetooth scale not appearing in the gateway app
|
||||
|
||||
- Wake up the scale (step on it or press its button)
|
||||
- Make sure Bluetooth and Location permissions are granted to the gateway app (Location is required by Android for BLE scanning)
|
||||
- Restart the gateway app
|
||||
|
||||
---
|
||||
|
||||
## Kiosk App
|
||||
|
||||
### Setup wizard can't find my server
|
||||
|
||||
- Make sure the tablet is on the same Wi-Fi network as the server
|
||||
- Try entering the URL manually instead of using auto-discovery
|
||||
- Check that the server responds on the expected port (80/443/8080/8443)
|
||||
|
||||
### Gateway install fails with an error dialog
|
||||
|
||||
The dialog shows the exact failure code. Common causes:
|
||||
|
||||
| Code | Cause | Fix |
|
||||
|------|-------|-----|
|
||||
| `STATUS_FAILURE` (1) | Generic install failure — often OEM restriction | Enable "Install from unknown sources" for the kiosk app in Android Settings |
|
||||
| `STATUS_FAILURE_CONFLICT` (3) | Signature mismatch with existing install | Uninstall the old gateway app, then retry |
|
||||
| `STATUS_FAILURE_STORAGE` (6) | Not enough storage | Free up space on the device |
|
||||
|
||||
### Exit button (✕) is not visible
|
||||
|
||||
The ✕ button is injected into the header by the kiosk app. If the web app's header is covered or the page failed to load, try the hard refresh (↻) button. If neither is visible, triple-tap the page title area to access the developer settings.
|
||||
|
||||
### App is stuck in kiosk mode after a crash
|
||||
|
||||
Restart the device. Screen pinning is released on reboot.
|
||||
|
||||
---
|
||||
|
||||
## General
|
||||
|
||||
### The version shown in the app is outdated
|
||||
|
||||
The version is cached by the browser. Do a hard refresh:
|
||||
- Desktop: `Ctrl+Shift+R` / `Cmd+Shift+R`
|
||||
- Android: tap the ↻ button (kiosk) or clear site data in Chrome settings
|
||||
|
||||
### Transactions are missing from the log
|
||||
|
||||
The log shows the last 50 entries by default. Tap "Carica altri" to load more. Entries older than the database creation date won't appear.
|
||||
|
||||
### "Can only undo transactions within 24 hours"
|
||||
|
||||
The undo window is 24 hours. For older operations, manually correct the inventory via the Edit function on the affected product.
|
||||
|
||||
### Error reports keep creating duplicate GitHub issues
|
||||
|
||||
EverShelf uses a fingerprint to deduplicate — the same error from the same device won't create a new issue within 24 hours. If you're seeing duplicates, check the `data/rate_limits/` folder and clear old files.
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Open an issue:** [github.com/dadaloop82/EverShelf/issues](https://github.com/dadaloop82/EverShelf/issues)
|
||||
- **Email:** [evershelfproject@gmail.com](mailto:evershelfproject@gmail.com)
|
||||
- **Try the demo:** [evershelfproject.dadaloop.it/demo](https://evershelfproject.dadaloop.it/demo)
|
||||
|
||||
When reporting a bug, include:
|
||||
1. EverShelf version (shown in the header as `v1.x.x`)
|
||||
2. Browser and OS
|
||||
3. Steps to reproduce
|
||||
4. Any error messages from the browser console (F12)
|
||||
@@ -0,0 +1,219 @@
|
||||
# ✨ Features
|
||||
|
||||
A complete walkthrough of EverShelf's features.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Inventory Management
|
||||
|
||||
### Adding Products
|
||||
|
||||
- Tap **➕** to open the add form
|
||||
- Search by name or scan a barcode
|
||||
- Select storage location: Pantry, Fridge, Freezer, or a custom location
|
||||
- Enter quantity and expiry date (or let AI estimate it)
|
||||
- Mark as vacuum-sealed or opened for adjusted shelf-life calculation
|
||||
|
||||
### Barcode Scanning
|
||||
|
||||
Tap the barcode icon to open the camera scanner (QuaggaJS). The app:
|
||||
1. Checks your local database first
|
||||
2. Falls back to [Open Food Facts](https://world.openfoodfacts.org/) for unknown barcodes
|
||||
3. Pre-fills the product form with name, brand, category
|
||||
|
||||
### AI Product Identification
|
||||
|
||||
Point the camera at any product — Gemini identifies it and:
|
||||
- Shows matching products **already in your pantry** first
|
||||
- Suggests a new product entry with pre-filled fields
|
||||
- Provides a storage location hint and estimated shelf-life
|
||||
|
||||
### Storage Locations
|
||||
|
||||
| Location | Icon | Notes |
|
||||
|----------|------|-------|
|
||||
| Pantry | 🏠 | Room temperature |
|
||||
| Fridge | ❄️ | Refrigerated |
|
||||
| Freezer | 🧊 | Frozen |
|
||||
| Custom | 📦 | Any name you choose |
|
||||
|
||||
### Opened Product Tracking
|
||||
|
||||
When you partially use a product and mark it as "opened":
|
||||
- Shelf-life is recalculated from the opening date
|
||||
- Uses AI (Gemini) + per-category rules (e.g. fish: 2 days, milk: 3 days)
|
||||
- Whole sealed packages always keep their original manufacturer expiry
|
||||
- Products with mixed whole + fractional units show as two separate entries
|
||||
|
||||
### Vacuum-Sealed Support
|
||||
|
||||
Mark any product as vacuum-sealed to extend its estimated expiry date (typically 2–3× the normal shelf-life).
|
||||
|
||||
---
|
||||
|
||||
## 🤖 AI Features (Google Gemini)
|
||||
|
||||
All AI features require a `GEMINI_API_KEY` in `.env`. They degrade gracefully when the key is missing or quota is exceeded.
|
||||
|
||||
### Expiry Date Reading
|
||||
|
||||
Photograph the label on a product — Gemini extracts the expiry date and fills the field automatically.
|
||||
|
||||
### Product Identification
|
||||
|
||||
Camera-based identification with pantry matching. See [Adding Products](#adding-products) above.
|
||||
|
||||
### Storage & Shelf-life Hint
|
||||
|
||||
When adding a new product, a background Gemini call suggests:
|
||||
- Optimal storage location
|
||||
- Estimated shelf-life in days
|
||||
|
||||
Shown as an inline AI badge next to the expiry estimate. Does not block the form.
|
||||
|
||||
### Recipe Generation
|
||||
|
||||
Tap **🍳 Ricette** → **Genera ricetta** to get a recipe using:
|
||||
- Ingredients about to expire (prioritised)
|
||||
- What's currently in your pantry
|
||||
- Your language preference
|
||||
|
||||
Recipes stream live via Server-Sent Events so results appear as they are generated.
|
||||
|
||||
### AI Chat Assistant
|
||||
|
||||
Open **💬 Chat** to ask questions like:
|
||||
- "Cosa posso fare con le uova e la pasta?"
|
||||
- "Quanti giorni dura il prosciutto cotto aperto in frigo?"
|
||||
- "Suggeriscimi uno spuntino veloce"
|
||||
|
||||
The assistant knows your current inventory.
|
||||
|
||||
### Shopping Suggestions with Tips
|
||||
|
||||
Smart shopping predictions include a short AI-generated practical tip per item (e.g. "Buy the 2 kg bag — it freezes well").
|
||||
|
||||
### Anomaly Explanation
|
||||
|
||||
When the dashboard shows a suspicious quantity banner, tap **🤖 Spiega** to get a plain-language explanation of why the discrepancy likely occurred and what to do about it.
|
||||
|
||||
### Model Fallback
|
||||
|
||||
All AI endpoints try `gemini-2.5-flash` first and automatically fall back to `gemini-2.0-flash` if unavailable.
|
||||
|
||||
---
|
||||
|
||||
## 🛒 Shopping List (Bring! Integration)
|
||||
|
||||
Configure `BRING_EMAIL` and `BRING_PASSWORD` in `.env` to enable.
|
||||
|
||||
### Features
|
||||
|
||||
- **View and manage** your Bring! list inside EverShelf
|
||||
- **Auto-add on depletion** — when stock hits zero, the product is added to Bring! automatically
|
||||
- **Auto-remove on scan** — scanning a product in removes it from the shopping list
|
||||
- **Generic names** — products are grouped by type ("Latte", "Panna da cucina") not brand, keeping the list clean
|
||||
- **Auto-migration** — items already on Bring! are silently renamed to their generic name on list load
|
||||
- **Catalog coverage** — 100+ product types mapped to Bring! catalog keys for icons and categories in the Bring! app
|
||||
- **AI fallback** — unknown product types use Gemini to determine the best generic name
|
||||
|
||||
---
|
||||
|
||||
## 🍳 Cooking Mode
|
||||
|
||||
Start cooking mode from any recipe by tapping **▶ Avvia cottura**.
|
||||
|
||||
### Features
|
||||
|
||||
- **Step-by-step guidance** — fullscreen, distraction-free interface
|
||||
- **Text-to-Speech** — each step is read aloud automatically when you navigate; supports:
|
||||
- Browser Web Speech API (default)
|
||||
- Native Android TTS (kiosk app)
|
||||
- Custom REST endpoint (e.g. Home Assistant)
|
||||
- **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
|
||||
- **Recipe completion** — "Buon appetito!" spoken on the last step
|
||||
|
||||
---
|
||||
|
||||
## 📊 Dashboard
|
||||
|
||||
### Inventory Overview
|
||||
|
||||
Three stat cards at the top show item counts for Pantry, Fridge, and Freezer with animated skeleton loading while data fetches.
|
||||
|
||||
### Expiry Alerts Banner
|
||||
|
||||
Priority-sorted notifications for:
|
||||
- Expired products (with safety assessment — green ✅ safe, amber 👀 check, red 🚫 danger)
|
||||
- Products expiring within 3 days
|
||||
|
||||
Actions per item: Use, Throw away, Edit, Dismiss. Swipe or tap arrows to navigate.
|
||||
|
||||
### Anomaly Banner
|
||||
|
||||
Highlights suspicious quantities (e.g. "You have 0 eggs but used 12 this month"). Actions:
|
||||
- One-tap correction to the suggested quantity
|
||||
- Inline edit with free-form quantity
|
||||
- "🤖 Spiega" for AI explanation
|
||||
- Dismiss (with current quantity shown: "La quantità è giusta (2 pz)")
|
||||
|
||||
### Anti-Waste Report
|
||||
|
||||
Shows your waste rate vs. the national average with an estimated annual kg of food wasted.
|
||||
|
||||
### Quick Recipe Bar
|
||||
|
||||
One-tap recipe suggestion using the ingredients closest to expiry.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Progressive Web App (PWA)
|
||||
|
||||
EverShelf is installable as a PWA on any device:
|
||||
|
||||
1. Open in Chrome/Safari/Edge
|
||||
2. Tap **"Add to Home Screen"** (browser menu)
|
||||
3. Launch from the home screen like a native app
|
||||
|
||||
Features:
|
||||
- Offline-capable shell (assets cached)
|
||||
- Full-screen mode on mobile
|
||||
- Multi-device: all data syncs via the shared server
|
||||
|
||||
---
|
||||
|
||||
## 🔔 Update Notifications
|
||||
|
||||
When a new EverShelf release is published on GitHub, a small pill appears in the header. Click it to see the changelog. Checked on load and every 30 minutes.
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Multi-language
|
||||
|
||||
The app auto-detects your browser language. Supported: 🇮🇹 Italian, 🇬🇧 English, 🇩🇪 German.
|
||||
|
||||
Change the language in **Settings → Language**.
|
||||
|
||||
See [Translations](Translations) to add a new language.
|
||||
|
||||
---
|
||||
|
||||
## ↩ Transaction History & Undo
|
||||
|
||||
**Settings → Storico** shows all inventory operations (adds, uses, throws).
|
||||
|
||||
- Any operation within the **last 24 hours** shows a red ↩ undo button
|
||||
- Tapping ↩ shows a 5-second countdown confirmation before reversing the transaction
|
||||
- The original stock is restored and a counter-transaction is logged
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
- API keys never exposed to the browser (`get_settings` returns boolean flags only)
|
||||
- `save_settings` protected by optional `SETTINGS_TOKEN` (validated with `hash_equals`)
|
||||
- `DEMO_MODE=true` blocks all write operations at the PHP router level
|
||||
- Parameterized SQL queries (PDO prepared statements) throughout
|
||||
- Input validation on all inventory operations (quantity bounds, location whitelist)
|
||||
- See [Configuration](Configuration) for details
|
||||
@@ -0,0 +1,93 @@
|
||||
# 🏠 EverShelf Wiki
|
||||
|
||||
Welcome to the **EverShelf** project wiki — your complete reference for installation, configuration, features, and development.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Try it now
|
||||
|
||||
> **[▶ Live Demo](https://evershelfproject.dadaloop.it/demo)** — no installation, no login, full AI enabled
|
||||
> **[🌐 Project Website](https://evershelfproject.dadaloop.it/)**
|
||||
|
||||
---
|
||||
|
||||
## 📚 Wiki Contents
|
||||
|
||||
| Page | Description |
|
||||
|------|-------------|
|
||||
| [Installation](Installation) | Docker, manual setup, HTTPS, web server config |
|
||||
| [Configuration](Configuration) | `.env` reference — all options explained |
|
||||
| [Features](Features) | Complete feature documentation |
|
||||
| [API Reference](API-Reference) | All REST endpoints, parameters, and responses |
|
||||
| [Android Kiosk](Android-Kiosk) | Tablet kiosk app setup and usage |
|
||||
| [Scale Gateway](Scale-Gateway) | BLE smart scale integration |
|
||||
| [Translations](Translations) | Adding and editing language files |
|
||||
| [Contributing](Contributing) | Development workflow and PR process |
|
||||
| [FAQ & Troubleshooting](FAQ) | Common issues and solutions |
|
||||
|
||||
---
|
||||
|
||||
## ✨ What is EverShelf?
|
||||
|
||||
EverShelf is a **self-hosted pantry management system** that runs entirely on your own server. It:
|
||||
|
||||
- Tracks food inventory across multiple storage locations (pantry, fridge, freezer, custom)
|
||||
- Scans barcodes and uses **Google Gemini AI** to identify products from photos
|
||||
- Suggests recipes based on what's in your pantry — especially items about to expire
|
||||
- Predicts what you'll need to buy before you run out
|
||||
- Integrates with the **Bring!** shopping list app
|
||||
- Supports a **BLE smart scale** for weight-based tracking
|
||||
- Runs as a **Progressive Web App** installable on any device
|
||||
- Optionally pairs with a dedicated **Android kiosk tablet app**
|
||||
|
||||
All data stays on your server. No cloud, no subscriptions.
|
||||
|
||||
---
|
||||
|
||||
## 🆕 What's New
|
||||
|
||||
### v1.7.1 (2026-05-04)
|
||||
- Destructive actions ("Butta tutto", "Finisci tutto") now require a **5-second countdown confirmation** before executing
|
||||
- History undo button ↩ is now clearly visible (red tint, larger)
|
||||
- Undo confirmation uses the in-app modal instead of the native browser `confirm()`
|
||||
|
||||
### v1.7.0 (2026-05-04)
|
||||
- Smart auto-discovery rewrite (kiosk)
|
||||
- Gateway auto-pre-configuration after install
|
||||
- ErrorReporter init at setup start
|
||||
- Graceful Bring! no-key state
|
||||
- Use-quantity guard with shake animation
|
||||
- Demo mode (`?demo=1`)
|
||||
|
||||
→ See the full [CHANGELOG](https://github.com/dadaloop82/EverShelf/blob/main/CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Repository Structure
|
||||
|
||||
```
|
||||
EverShelf/
|
||||
├── index.html # Single-page application entry point
|
||||
├── manifest.json # PWA manifest
|
||||
├── .env.example # Configuration template
|
||||
├── api/
|
||||
│ ├── index.php # Main API router
|
||||
│ ├── database.php # SQLite schema + migrations
|
||||
│ └── cron_smart_shopping.php # Background predictions job
|
||||
├── assets/
|
||||
│ ├── css/style.css
|
||||
│ ├── js/app.js
|
||||
│ └── img/
|
||||
├── translations/ # i18n JSON files (it, en, de)
|
||||
├── docs/openapi.yaml # OpenAPI 3.0 spec
|
||||
├── evershelf-kiosk/ # Android kiosk app (Kotlin)
|
||||
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT — free to use, modify, and distribute. See [LICENSE](https://github.com/dadaloop82/EverShelf/blob/main/LICENSE).
|
||||
|
||||
**Author:** Stimpfl Daniel — [evershelfproject@gmail.com](mailto:evershelfproject@gmail.com)
|
||||
@@ -0,0 +1,233 @@
|
||||
# 📦 Installation
|
||||
|
||||
EverShelf runs on any server with PHP 8.0+ and SQLite. Docker is the recommended approach for the fastest setup.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Minimum | Notes |
|
||||
|-------------|---------|-------|
|
||||
| PHP | 8.0+ | Extensions: `pdo_sqlite`, `curl`, `mbstring`, `json` |
|
||||
| Web server | Apache 2.4+ or Nginx | Apache `.htaccess` included |
|
||||
| SQLite | 3.x | Bundled with PHP on most distros |
|
||||
| HTTPS | Recommended | Required for camera access on mobile browsers |
|
||||
| RAM | 256 MB | 512 MB+ recommended if using AI features |
|
||||
|
||||
---
|
||||
|
||||
## Option A: Docker (recommended)
|
||||
|
||||
The fastest way to get started.
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/dadaloop82/EverShelf.git
|
||||
cd EverShelf
|
||||
|
||||
# 2. Create your configuration
|
||||
cp .env.example .env
|
||||
nano .env # set GEMINI_API_KEY and other options
|
||||
|
||||
# 3. Start
|
||||
docker compose up -d
|
||||
|
||||
# 4. Open in browser
|
||||
# → http://localhost:8080
|
||||
```
|
||||
|
||||
The Docker image:
|
||||
- Uses PHP-Apache on Debian Bookworm slim
|
||||
- Auto-creates the `data/` directory with correct permissions
|
||||
- Exposes port `8080` by default (configurable in `docker-compose.yml`)
|
||||
- Persists data in a named Docker volume
|
||||
|
||||
### Changing the port
|
||||
|
||||
Edit `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "8080:80" # change 8080 to your desired host port
|
||||
```
|
||||
|
||||
### Using HTTPS with Docker
|
||||
|
||||
Add a reverse proxy (e.g. Traefik, Caddy, or Nginx Proxy Manager) in front of the container for automatic TLS.
|
||||
|
||||
---
|
||||
|
||||
## Option B: Manual (Apache)
|
||||
|
||||
```bash
|
||||
# 1. Clone into your web root
|
||||
git clone https://github.com/dadaloop82/EverShelf.git /var/www/html/dispensa
|
||||
cd /var/www/html/dispensa
|
||||
|
||||
# 2. Create configuration
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
# 3. Set permissions on the data directory
|
||||
chmod 755 data/
|
||||
chown -R www-data:www-data data/
|
||||
```
|
||||
|
||||
Make sure `mod_rewrite` is enabled:
|
||||
|
||||
```bash
|
||||
sudo a2enmod rewrite
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
||||
Apache virtual host (or add to `.htaccess` which is already included):
|
||||
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName evershelf.local
|
||||
DocumentRoot /var/www/html/dispensa
|
||||
|
||||
<Directory /var/www/html/dispensa>
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
# Hide sensitive paths
|
||||
<LocationMatch "^/(data|\.env|backup\.sh)">
|
||||
Require all denied
|
||||
</LocationMatch>
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/ssl/certs/evershelf.crt
|
||||
SSLCertificateKeyFile /etc/ssl/private/evershelf.key
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Option C: Manual (Nginx)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name evershelf.local;
|
||||
root /var/www/html/dispensa;
|
||||
index index.html;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/evershelf.crt;
|
||||
ssl_certificate_key /etc/ssl/private/evershelf.key;
|
||||
|
||||
location /api/ {
|
||||
try_files $uri $uri/ =404;
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
}
|
||||
|
||||
# Block sensitive files
|
||||
location ~ /\.env { deny all; }
|
||||
location ~ /data/ { deny all; }
|
||||
location ~ /backup\.sh { deny all; }
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTPS Setup
|
||||
|
||||
Camera and microphone access (barcode scanning, voice) **require HTTPS** on all modern mobile browsers.
|
||||
|
||||
### Self-signed certificate (local network)
|
||||
|
||||
```bash
|
||||
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||
-keyout /etc/ssl/private/evershelf.key \
|
||||
-out /etc/ssl/certs/evershelf.crt \
|
||||
-subj "/CN=evershelf.local" \
|
||||
-addext "subjectAltName=IP:192.168.1.100,DNS:evershelf.local"
|
||||
```
|
||||
|
||||
Android will show a certificate warning — tap "Advanced → Proceed" once. The kiosk app accepts self-signed certificates automatically.
|
||||
|
||||
### Let's Encrypt (public server)
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-apache
|
||||
sudo certbot --apache -d evershelf.yourdomain.com
|
||||
```
|
||||
|
||||
### Caddy (automatic TLS)
|
||||
|
||||
```
|
||||
evershelf.yourdomain.com {
|
||||
root * /var/www/html/dispensa
|
||||
php_fastcgi unix//run/php/php8.2-fpm.sock
|
||||
file_server
|
||||
respond /data/* 403
|
||||
respond /.env 403
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cron Job (optional)
|
||||
|
||||
For smart shopping predictions to stay up to date:
|
||||
|
||||
```bash
|
||||
# Edit crontab
|
||||
crontab -e
|
||||
|
||||
# Add (runs every 5 minutes)
|
||||
*/5 * * * * php /var/www/html/dispensa/api/cron_smart_shopping.php >> /var/www/html/dispensa/data/cron.log 2>&1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup (optional)
|
||||
|
||||
```bash
|
||||
# Edit crontab
|
||||
crontab -e
|
||||
|
||||
# Daily backup at 3 AM
|
||||
0 3 * * * /var/www/html/dispensa/backup.sh
|
||||
```
|
||||
|
||||
The `backup.sh` script copies `data/evershelf.db` to `data/backups/` with a timestamp.
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
cd /var/www/html/dispensa
|
||||
git pull origin main
|
||||
# Database migrations run automatically on next page load
|
||||
```
|
||||
|
||||
With Docker:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Post-installation
|
||||
|
||||
Once the app is running, open it in your browser and:
|
||||
|
||||
1. Go to **Settings** (⚙️ icon in the header)
|
||||
2. Enter your **Gemini API key** (get one free at [aistudio.google.com](https://aistudio.google.com/app/apikey))
|
||||
3. Optionally configure Bring!, TTS, and scale settings
|
||||
4. Add your first product via the ➕ button or barcode scan
|
||||
|
||||
See [Configuration](Configuration) for the full list of settings.
|
||||
@@ -0,0 +1,155 @@
|
||||
# ⚖️ Scale Gateway
|
||||
|
||||
The EverShelf Scale Gateway is an Android app that bridges a Bluetooth LE smart scale to EverShelf, enabling automatic weight-based inventory tracking.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Smart Scale
|
||||
│ (Bluetooth LE)
|
||||
▼
|
||||
Android device (Scale Gateway app)
|
||||
│ (WebSocket — ws://127.0.0.1:8765)
|
||||
▼
|
||||
EverShelf Server (scale_relay.php — SSE relay)
|
||||
│ (Server-Sent Events)
|
||||
▼
|
||||
EverShelf Web App (auto-fills weight in add/use forms)
|
||||
```
|
||||
|
||||
The Gateway runs a local WebSocket server on port **8765**. The EverShelf server proxies scale readings to the browser via SSE, avoiding HTTPS→WS mixed-content issues.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
**[⬇ Download latest APK](https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk)**
|
||||
|
||||
> Current version: **v2.1.0** — requires Android 7.0+
|
||||
|
||||
---
|
||||
|
||||
## Supported Scales
|
||||
|
||||
| Protocol | BLE Service | Notes |
|
||||
|----------|------------|-------|
|
||||
| Bluetooth SIG Weight Scale | `0x181D` / char `0x2A9D` | Most compatible |
|
||||
| Bluetooth SIG Body Composition | `0x181B` / char `0x2A9C` | Weight + body fat |
|
||||
| Generic fallback | Any notifiable characteristic | Auto-heuristic for 100+ models |
|
||||
|
||||
**Verified compatible models:**
|
||||
- Xiaomi Mi Body Composition Scale 2
|
||||
- Renpho Smart Body Fat Scale
|
||||
- Any scale supported by [openScale](https://github.com/oliexdev/openScale/wiki/Supported-scales)
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install
|
||||
|
||||
Download and install the APK. You may need to enable "Install from unknown sources" in Android Settings.
|
||||
|
||||
> **Kiosk users:** the Setup Wizard installs the gateway automatically in Step 5.
|
||||
|
||||
### 2. Launch the app
|
||||
|
||||
The gateway server starts immediately. Note the **Gateway URL** shown (e.g. `ws://192.168.1.100:8765`).
|
||||
|
||||
### 3. Configure EverShelf
|
||||
|
||||
In EverShelf **Settings → Scale**:
|
||||
- Enable scale integration
|
||||
- Enter the Gateway URL (or let auto-discovery find it)
|
||||
|
||||
> **Kiosk users:** this is done automatically during setup.
|
||||
|
||||
### 4. Connect your scale
|
||||
|
||||
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is powered on. Tap it in the list to pair and connect.
|
||||
|
||||
---
|
||||
|
||||
## Using the Scale in EverShelf
|
||||
|
||||
When scale integration is enabled:
|
||||
|
||||
1. Open the **Add** or **Use** form for any product with unit `g` or `ml`
|
||||
2. A **"⚖️ Leggi bilancia"** button appears
|
||||
3. Tap it — a live weight display appears with a stability indicator
|
||||
4. Step on or place the product on the scale
|
||||
5. When the reading stabilizes, a **5-second countdown** starts
|
||||
6. The weight auto-fills the quantity field and the form submits
|
||||
|
||||
### Thresholds and de-duplication
|
||||
|
||||
- **10g threshold** — readings that haven't changed enough between products are ignored to prevent stale readings
|
||||
- **12-second server-side dedup** — a second scale-triggered deduction of the same product within 12 seconds is rejected (guards against BLE multi-fire)
|
||||
- **ml conversion** — when the product unit is `ml`, the weight in grams is accepted and a hint is shown: "weight in grams → will be converted to ml"
|
||||
|
||||
---
|
||||
|
||||
## Scale Status Indicator
|
||||
|
||||
The header of the EverShelf web app shows a real-time scale status icon (⚖️):
|
||||
|
||||
| State | Meaning |
|
||||
|-------|---------|
|
||||
| ⚖️ green | Connected and ready |
|
||||
| ⚖️ amber | Searching / reconnecting |
|
||||
| ⚖️ grey | Disconnected |
|
||||
| ⚖️ red | Error |
|
||||
|
||||
---
|
||||
|
||||
## Update Notifications
|
||||
|
||||
Every 6 hours the gateway app checks GitHub releases. If a newer version is available, a banner appears with a one-tap download and install.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Scale not appearing in the Bluetooth list
|
||||
- Make sure BLE is enabled on the Android device
|
||||
- Step on/shake the scale to wake it up (most scales enter sleep mode quickly)
|
||||
- Some scales only advertise while someone stands on them
|
||||
|
||||
### Weight not appearing in EverShelf
|
||||
- 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
|
||||
- Tap "Disconnetti / Riconnetti" in the gateway app to refresh the WebSocket connection
|
||||
|
||||
### "Mixed content" error in browser
|
||||
- Make sure you are accessing EverShelf over HTTPS (not plain HTTP)
|
||||
- The SSE relay (`scale_relay.php`) handles the HTTP→WS bridging — ensure the relay script is reachable
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
```bash
|
||||
cd evershelf-scale-gateway
|
||||
./gradlew assembleRelease
|
||||
# APK: app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
Requires Android Studio or JDK 17+ with the Android SDK.
|
||||
|
||||
---
|
||||
|
||||
## BLE Protocol Details
|
||||
|
||||
The gateway uses the following GATT profile order:
|
||||
|
||||
1. **Weight Scale** (`0x181D`) — standard weight only
|
||||
2. **Body Composition** (`0x181B`) — weight + additional metrics
|
||||
3. **Generic fallback** — subscribes to all notifiable characteristics and applies a heuristic parser that handles byte-order variations used by the majority of consumer smart scales
|
||||
|
||||
Weight values are extracted in kg, converted to grams, and broadcast over WebSocket as:
|
||||
|
||||
```json
|
||||
{ "weight_g": 1234, "stable": true, "unit": "g" }
|
||||
```
|
||||
@@ -0,0 +1,143 @@
|
||||
# 🌍 Translations
|
||||
|
||||
EverShelf uses JSON translation files in the `translations/` folder. The app auto-detects the browser language on load and falls back to English.
|
||||
|
||||
---
|
||||
|
||||
## Currently Supported Languages
|
||||
|
||||
| Language | File | Status |
|
||||
|----------|------|--------|
|
||||
| 🇮🇹 Italian | `translations/it.json` | ✅ Complete (base language) |
|
||||
| 🇬🇧 English | `translations/en.json` | ✅ Complete |
|
||||
| 🇩🇪 German | `translations/de.json` | ✅ Complete |
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Language
|
||||
|
||||
### 1. Copy the base file
|
||||
|
||||
```bash
|
||||
cp translations/it.json translations/fr.json
|
||||
```
|
||||
|
||||
### 2. Translate all values
|
||||
|
||||
Open `fr.json` in your editor and translate every **value** (leave the **keys** unchanged).
|
||||
|
||||
```json
|
||||
{
|
||||
"app": {
|
||||
"name": "EverShelf",
|
||||
"loading": "Chargement..." ← translate this
|
||||
},
|
||||
"nav": {
|
||||
"title": "🏠 EverShelf", ← keep emoji, translate text
|
||||
"home": "Accueil"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Never change the key names (left side of `:`)
|
||||
- Keep `{placeholder}` tokens unchanged — they are replaced at runtime
|
||||
- Example: `"toast.added": "Added {name} to {location}"` — keep `{name}` and `{location}`
|
||||
- Keep HTML tags if present (rare): `<strong>`, `<br>`
|
||||
- Keep emojis (they are part of the UX design)
|
||||
- Plurals: some keys have `_one` / `_many` variants — translate both
|
||||
|
||||
### 3. Register the language in the app
|
||||
|
||||
Open `assets/js/app.js` and find the `SUPPORTED_LANGUAGES` constant (near the top):
|
||||
|
||||
```js
|
||||
const SUPPORTED_LANGUAGES = ['it', 'en', 'de'];
|
||||
```
|
||||
|
||||
Add your language code:
|
||||
|
||||
```js
|
||||
const SUPPORTED_LANGUAGES = ['it', 'en', 'de', 'fr'];
|
||||
```
|
||||
|
||||
### 4. Add the language to `translations/` badge list
|
||||
|
||||
Update the `README.md` badge:
|
||||
|
||||
```markdown
|
||||
[](translations/)
|
||||
```
|
||||
|
||||
### 5. Test
|
||||
|
||||
Open the app with `?lang=fr` in the URL to force your language:
|
||||
|
||||
```
|
||||
http://localhost:8080/?lang=fr
|
||||
```
|
||||
|
||||
Check for missing keys — they will show the raw key name in the UI (e.g. `nav.title`).
|
||||
|
||||
### 6. Submit a PR
|
||||
|
||||
Open a pull request with your new `translations/fr.json` and the updated `app.js` line. See [Contributing](Contributing).
|
||||
|
||||
---
|
||||
|
||||
## Translation Key Structure
|
||||
|
||||
The file is a nested JSON object. Here are the main sections:
|
||||
|
||||
| Section | Description |
|
||||
|---------|-------------|
|
||||
| `app` | General app strings |
|
||||
| `nav` | Navigation labels |
|
||||
| `btn` | Button labels |
|
||||
| `locations` | Storage location names |
|
||||
| `categories` | Product category names |
|
||||
| `dashboard` | Dashboard section titles |
|
||||
| `inventory` | Inventory page strings |
|
||||
| `use` | Use/consume form strings |
|
||||
| `add` | Add product form strings |
|
||||
| `scan` | Barcode scanner strings |
|
||||
| `recipes` | Recipe page strings |
|
||||
| `cooking` | Cooking mode strings |
|
||||
| `shopping` | Shopping list strings |
|
||||
| `log` | Transaction log strings |
|
||||
| `settings` | Settings page strings |
|
||||
| `scale` | Scale integration strings |
|
||||
| `toast` | Toast notification messages |
|
||||
| `error` | Error messages |
|
||||
| `confirm` | Confirmation dialog strings |
|
||||
|
||||
---
|
||||
|
||||
## Updating Existing Translations
|
||||
|
||||
If a new feature adds keys to `it.json` (the base), you need to add the same keys to `en.json` and `de.json`.
|
||||
|
||||
The CI pipeline validates that all language files contain the same keys — a missing key will fail the build.
|
||||
|
||||
To check locally:
|
||||
|
||||
```bash
|
||||
node -e "
|
||||
const it = require('./translations/it.json');
|
||||
const en = require('./translations/en.json');
|
||||
// flatten and compare keys...
|
||||
"
|
||||
```
|
||||
|
||||
Or just open a PR — CI will flag any missing keys automatically.
|
||||
|
||||
---
|
||||
|
||||
## Language Detection Order
|
||||
|
||||
1. `?lang=xx` URL parameter (forces a specific language)
|
||||
2. `localStorage.getItem('lang')` (last manually selected language)
|
||||
3. `navigator.language` / `navigator.languages` (browser preference)
|
||||
4. Fallback: `en`
|
||||
|
||||
Users can change the language in **Settings → Language**.
|
||||
@@ -0,0 +1,167 @@
|
||||
# EverShelf Kiosk
|
||||
|
||||
Android kiosk app for wall-mounted kitchen tablets. Full-screen WebView wrapper with integrated BLE scale gateway — no external apps required.
|
||||
|
||||
> **Version:** 1.6.0 (versionCode 10)
|
||||
> **Package:** `it.dadaloop.evershelf.kiosk`
|
||||
> **Min SDK:** Android 7.0 (API 24)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Kiosk Mode
|
||||
- **Full-screen WebView** — immersive mode hides status bar and navigation bar
|
||||
- **True kiosk lock** — screen pinning (`startLockTask`) blocks home/recent/back buttons
|
||||
- **Exit button (✕)** — visible in header, requires confirmation dialog to exit kiosk
|
||||
- **Hard refresh (↻)** — clears WebView cache to pick up web app updates instantly
|
||||
- **SSL support** — accepts self-signed certificates for local HTTPS servers
|
||||
- **Update notifications** — checks GitHub releases every 6 hours, shows auto-dismiss banner
|
||||
- **Native TTS bridge** — cooking mode voice readout uses Android TextToSpeech directly
|
||||
- **Settings activity** — change server URL, test connection, re-run setup wizard
|
||||
|
||||
### BLE Scale Gateway (integrated, no external app)
|
||||
- **Built-in BLE gateway** — `GatewayService` foreground service handles BLE scanning and connection automatically when a scale is configured
|
||||
- **WebSocket server** — exposes scale data on `ws://127.0.0.1:8765`, fully protocol-compatible with the legacy standalone gateway app (no webapp JS changes needed)
|
||||
- **Auto-start** — service starts automatically on kiosk launch if a scale device is configured
|
||||
- **Auto-reconnect** — reconnects automatically after 8 seconds if the BLE link drops
|
||||
- **Multi-protocol** — supports Bluetooth SIG Weight Scale (`0x181D`/`0x2A9D`), Body Composition (`0x181B`/`0x2A9C`), QN/Yolanda scales, and 100+ models via generic fallback heuristic
|
||||
|
||||
### Setup Wizard (6 steps)
|
||||
1. **Language** — choose Italian / English / German
|
||||
2. **Welcome** — intro and privacy information
|
||||
3. **Permissions** — camera, microphone, BLE permissions with in-wizard grant flow
|
||||
4. **Server URL** — enter your EverShelf URL; auto-discovery scans the LAN (60 parallel threads, ports 80/443/8080/8443)
|
||||
5. **Smart Scale** — optional: scan for BLE scales and select yours from the discovered device list (mandatory before proceeding if you choose "yes")
|
||||
6. **Screensaver** — toggle display sleep after inactivity
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
KioskActivity (WebView — full-screen EverShelf)
|
||||
├── SetupActivity (6-step wizard, shown on first launch only)
|
||||
├── SettingsActivity (URL, scale status, re-run wizard)
|
||||
├── Immersive mode (SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
|
||||
├── Screen pinning (startLockTask / stopLockTask)
|
||||
├── JS bridge (_kioskBridge: exit, hardReload)
|
||||
└── GatewayService (foreground service — BLE + WebSocket)
|
||||
├── BleScaleManager — BLE scanning, GATT, auto-reconnect
|
||||
├── GatewayWebSocketServer — WebSocket server :8765
|
||||
└── ScaleProtocol — multi-protocol BLE weight parser
|
||||
```
|
||||
|
||||
The kiosk app is fully self-contained. No separate gateway app is required.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the **EverShelf Kiosk** APK on your Android tablet
|
||||
2. Launch the app — the setup wizard starts automatically
|
||||
3. Choose your language
|
||||
4. Grant camera, microphone and Bluetooth permissions when prompted
|
||||
5. Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`) or use auto-discovery
|
||||
6. If you have a Bluetooth scale: tap **"Sì, ho una bilancia"**, wait for the BLE scan, then tap your scale in the list
|
||||
7. Done — the web app loads in full-screen kiosk mode
|
||||
|
||||
### Scale Configuration
|
||||
|
||||
BLE scale setup happens inside the kiosk app itself — **no external app needed**:
|
||||
|
||||
- During the **setup wizard (step 5)**, the app scans for nearby BLE scales and shows them in a list. Devices most likely to be scales are marked with ⭐.
|
||||
- Tap a device to select it. The selection is saved and the "Next" button becomes enabled.
|
||||
- From the **Settings screen**, you can restart the BLE service or reconfigure the scale device.
|
||||
|
||||
### Exiting Kiosk Mode
|
||||
|
||||
Tap the **✕** button in the header. A confirmation dialog appears — tap "Esci" to exit.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Purpose |
|
||||
|---|---|
|
||||
| `INTERNET` | Load EverShelf web app |
|
||||
| `ACCESS_NETWORK_STATE` | Check connectivity |
|
||||
| `ACCESS_WIFI_STATE` | LAN subnet detection for auto-discovery |
|
||||
| `WAKE_LOCK` | Keep screen on |
|
||||
| `CAMERA` | Barcode scanning, AI photo identification |
|
||||
| `RECORD_AUDIO` | Voice input in chat assistant |
|
||||
| `READ_MEDIA_IMAGES` / `READ_EXTERNAL_STORAGE` | Image access for AI scan |
|
||||
| `REORDER_TASKS` | Bring kiosk to foreground |
|
||||
| `BLUETOOTH` / `BLUETOOTH_ADMIN` | BLE (Android ≤ 11) |
|
||||
| `BLUETOOTH_SCAN` / `BLUETOOTH_CONNECT` | BLE scan and connect (Android 12+) |
|
||||
| `ACCESS_FINE_LOCATION` | Required for BLE scan on Android < 12 |
|
||||
| `FOREGROUND_SERVICE` | Run BLE gateway as foreground service |
|
||||
| `FOREGROUND_SERVICE_CONNECTED_DEVICE` | Service type for BLE (Android 14+) |
|
||||
|
||||
---
|
||||
|
||||
## Supported Scale Protocols
|
||||
|
||||
| Protocol | Service UUID | Notes |
|
||||
|---|---|---|
|
||||
| **Bluetooth SIG Weight Scale** | `0x181D` / char `0x2A9D` | Most compatible |
|
||||
| **Bluetooth SIG Body Composition** | `0x181B` / char `0x2A9C` | Weight + body fat %, BMI |
|
||||
| **QN/Yolanda** | Custom UUIDs | Xiaomi Mi Scale 2, Renpho, etc. |
|
||||
| **Generic fallback** | Any notifiable characteristic | Auto-heuristic parsing for 100+ models |
|
||||
|
||||
### Verified compatible scales
|
||||
- Xiaomi Mi Body Composition Scale 2
|
||||
- Renpho Smart Body Fat Scale
|
||||
- INEVIFIT Smart Body Fat Scale
|
||||
- Any [openScale-compatible scale](https://github.com/oliexdev/openScale/wiki/Supported-scales)
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Protocol
|
||||
|
||||
The built-in WebSocket server speaks the same protocol as the legacy standalone gateway app — the EverShelf webapp needs no changes.
|
||||
|
||||
**Server → client:**
|
||||
```json
|
||||
{"type":"status","state":"connected","device":"Mi Scale 2","battery":85}
|
||||
{"type":"status","state":"disconnected"}
|
||||
{"type":"weight","value":72.50,"unit":"kg","stable":true,"timestamp":1712345678000}
|
||||
{"type":"pong"}
|
||||
```
|
||||
|
||||
**Client → server:**
|
||||
```json
|
||||
{"type":"get_status"}
|
||||
{"type":"get_weight"}
|
||||
{"type":"ping"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd evershelf-kiosk
|
||||
./gradlew assembleDebug
|
||||
# APK at app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
For release:
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android 7.0+ (API 24)
|
||||
- Bluetooth LE support (for scale integration)
|
||||
- Network access to EverShelf server
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../LICENSE)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "it.dadaloop.evershelf.kiosk"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 13
|
||||
versionName = "1.7.2"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
// Project keystore — same on every machine so OTA updates always work.
|
||||
create("project") {
|
||||
storeFile = file("../evershelf.jks")
|
||||
storePassword = "evershelf123"
|
||||
keyAlias = "evershelf"
|
||||
keyPassword = "evershelf123"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig = signingConfigs.getByName("project")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("project")
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
}
|
||||
}
|
||||
|
||||
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.webkit:webkit:1.10.0")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
implementation("org.java-websocket:Java-WebSocket:1.5.5")
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Network -->
|
||||
<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 -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Camera & Microphone (for barcode scan, photo, voice) -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||
|
||||
<!-- Storage (file upload/download) -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
|
||||
<!-- Move task to front -->
|
||||
<uses-permission android:name="android.permission.REORDER_TASKS" />
|
||||
|
||||
<!-- Self-update: install own APK at runtime -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!-- ── BLE Scale Gateway (integrated) ───────────────────────────── -->
|
||||
<!-- Legacy BLE permissions (Android ≤ 11) -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<!-- Fine location required for BLE scan on Android < 12 -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
|
||||
<!-- Android 12+ BLE permissions -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<!-- Foreground service for keeping BLE+WebSocket alive -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<!-- BLE hardware — not required, app gracefully disables scale if absent -->
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".KioskActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="orientation|screenSize|keyboard|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".SetupActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar"
|
||||
android:configChanges="orientation|screenSize|keyboard|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar" />
|
||||
|
||||
<!-- GatewayService: runs BLE scan + WebSocket server as a foreground service -->
|
||||
<service
|
||||
android:name=".scale.GatewayService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" />
|
||||
|
||||
<!-- 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,354 @@
|
||||
package it.dadaloop.evershelf.kiosk
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.ApplicationExitInfo
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.json.JSONObject
|
||||
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 Kiosk.
|
||||
*
|
||||
* Sends structured JSON payloads to the EverShelf backend
|
||||
* (POST /api/?action=report_error) which in turn creates or
|
||||
* updates a GitHub Issue automatically.
|
||||
*
|
||||
* Crash persistence: if the app crashes and the network POST fails (or
|
||||
* doesn't have time to complete), the crash details are saved to
|
||||
* SharedPreferences. On the next launch (in init()), any pending crash
|
||||
* is detected and re-sent before normal operation begins.
|
||||
*
|
||||
* Usage:
|
||||
* // In Application or Activity onCreate:
|
||||
* ErrorReporter.init(this, prefs.getString("evershelf_url", "")!!)
|
||||
*
|
||||
* // To report a caught exception:
|
||||
* ErrorReporter.report(e, "myMethod", mapOf("extra" to "data"))
|
||||
*
|
||||
* // To report a non-exception event:
|
||||
* ErrorReporter.reportMessage("webview-crash", "WebView died unexpectedly")
|
||||
*/
|
||||
object ErrorReporter {
|
||||
|
||||
private const val TAG = "EverShelfErrorReporter"
|
||||
|
||||
// SharedPreferences for crash persistence
|
||||
private const val PREFS_NAME = "evershelf_kiosk_errors"
|
||||
private const val KEY_PENDING = "pending_crash_json"
|
||||
private const val KEY_WAS_RUNNING = "was_running_dirty"
|
||||
private const val KEY_LAST_EXIT_TS = "last_reported_exit_ts"
|
||||
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
|
||||
// Fingerprints already sent in this process to avoid flooding
|
||||
private val sentFingerprints = mutableSetOf<String>()
|
||||
|
||||
private var serverBaseUrl: String = ""
|
||||
private var appVersion: String = ""
|
||||
private var deviceInfo: String = ""
|
||||
private lateinit var appContext: Context
|
||||
|
||||
/**
|
||||
* Call once (e.g. in KioskActivity.onCreate) before reporting any errors.
|
||||
* @param context Application or Activity context.
|
||||
* @param baseUrl The EverShelf server URL, e.g. "http://192.168.1.10:8080"
|
||||
*/
|
||||
fun init(context: Context, baseUrl: String) {
|
||||
appContext = context.applicationContext
|
||||
serverBaseUrl = baseUrl.trimEnd('/')
|
||||
try {
|
||||
val pi = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
appVersion = pi.versionName ?: "unknown"
|
||||
} catch (_: Exception) {}
|
||||
deviceInfo = buildString {
|
||||
val mfr = Build.MANUFACTURER.takeIf { it.isNotBlank() && it != "unknown" }
|
||||
?: Build.PRODUCT.takeIf { it.isNotBlank() && it != "unknown" }
|
||||
?: Build.BOARD
|
||||
val model = Build.MODEL.takeIf { it.isNotBlank() && it != "unknown" }
|
||||
?: Build.HARDWARE
|
||||
append("$mfr $model (Android ${Build.VERSION.RELEASE}/${Build.VERSION.SDK_INT})")
|
||||
}
|
||||
|
||||
// Send any crash that was saved to prefs during a previous session
|
||||
sendPendingCrash()
|
||||
|
||||
// Detect ANR / OOM / native crashes from the previous run
|
||||
detectPreviousCrash()
|
||||
|
||||
// Install a global UncaughtExceptionHandler so ANY unhandled crash is reported
|
||||
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
val type = "uncaught-exception"
|
||||
val message = throwable.message ?: throwable.javaClass.simpleName
|
||||
val stack = throwable.stackTraceToString()
|
||||
val ctx = mapOf("thread" to thread.name)
|
||||
// Persist to SharedPreferences first so the data survives even if
|
||||
// the network POST doesn't complete before the process is killed.
|
||||
savePendingCrash(type, message, stack, ctx)
|
||||
reportSync(type, message, stack, ctx)
|
||||
// If reportSync succeeded, the issue was sent — clear the pending entry
|
||||
clearPendingCrash()
|
||||
} catch (_: Exception) {}
|
||||
// Re-throw to the previous handler so the system crash dialog/restart still works
|
||||
previousHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call from Activity.onDestroy() on a *clean* exit (back-pressed, settings, shutdown).
|
||||
* Clears the dirty-launch sentinel so the next start does not report a false positive.
|
||||
*/
|
||||
fun markCleanStop() {
|
||||
if (::appContext.isInitialized) {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(KEY_WAS_RUNNING, false).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a caught [Throwable] asynchronously (does not block UI thread).
|
||||
*/
|
||||
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)
|
||||
reportAsync(
|
||||
type = "kiosk-exception",
|
||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
||||
stack = throwable.stackTraceToString(),
|
||||
context = ctx
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a non-exception message (e.g. WebView page error, network failure).
|
||||
* @param forceReport if true, bypasses the in-session dedup so retries are always sent.
|
||||
*/
|
||||
fun reportMessage(
|
||||
type: String,
|
||||
message: String,
|
||||
extra: Map<String, Any?> = emptyMap(),
|
||||
forceReport: Boolean = false
|
||||
) {
|
||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
||||
ctx.putAll(extra)
|
||||
reportAsync(type = type, message = message, stack = "", context = ctx, force = forceReport)
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detects whether the *previous* run of the app ended with a crash, ANR or OOM kill.
|
||||
*
|
||||
* On Android 11+ (API 30) we use [ActivityManager.getHistoricalProcessExitReasons] which
|
||||
* gives the exact reason and (for Java crashes) a stack trace.
|
||||
*
|
||||
* On Android 7–10 we use a "dirty-launch sentinel": a boolean in SharedPreferences that is
|
||||
* set to `true` on every start and `false` only when the activity is destroyed cleanly via
|
||||
* [markCleanStop]. If it is still `true` on the next start, the previous run was not clean.
|
||||
*/
|
||||
private fun detectPreviousCrash() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
detectExitReasonApi30()
|
||||
} else {
|
||||
// API 24–29: dirty-launch sentinel
|
||||
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean(KEY_WAS_RUNNING, false)) {
|
||||
reportAsync(
|
||||
type = "crash-sentinel",
|
||||
message = "App was not cleanly shut down on previous run (ANR / OOM / native crash suspected).",
|
||||
stack = "",
|
||||
context = mapOf(
|
||||
"device" to deviceInfo,
|
||||
"note" to "Detected via dirty-launch sentinel (API ${Build.VERSION.SDK_INT})"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Mark this launch as running — will be cleared by markCleanStop() on clean exit
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(KEY_WAS_RUNNING, true).apply()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun detectExitReasonApi30() {
|
||||
try {
|
||||
val am = appContext.getSystemService(ActivityManager::class.java) ?: return
|
||||
// Check the last 5 exits; stop at the first we already reported
|
||||
val exits = am.getHistoricalProcessExitReasons(null, 0, 5)
|
||||
if (exits.isEmpty()) return
|
||||
|
||||
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val lastReportedTs = prefs.getLong(KEY_LAST_EXIT_TS, 0L)
|
||||
|
||||
val crashReasons = setOf(
|
||||
ApplicationExitInfo.REASON_CRASH,
|
||||
ApplicationExitInfo.REASON_CRASH_NATIVE,
|
||||
ApplicationExitInfo.REASON_ANR,
|
||||
ApplicationExitInfo.REASON_LOW_MEMORY
|
||||
)
|
||||
|
||||
var newestTs = lastReportedTs
|
||||
for (exit in exits) {
|
||||
if (exit.timestamp <= lastReportedTs) continue // already reported
|
||||
if (exit.reason !in crashReasons) continue
|
||||
|
||||
val reasonName = when (exit.reason) {
|
||||
ApplicationExitInfo.REASON_CRASH -> "crash-java"
|
||||
ApplicationExitInfo.REASON_CRASH_NATIVE -> "crash-native"
|
||||
ApplicationExitInfo.REASON_ANR -> "anr"
|
||||
ApplicationExitInfo.REASON_LOW_MEMORY -> "oom-kill"
|
||||
else -> "exit-${exit.reason}"
|
||||
}
|
||||
val msg = exit.description?.takeIf { it.isNotEmpty() }
|
||||
?: "${exit.processName ?: "app"} terminated (reason ${exit.reason})"
|
||||
|
||||
// Java crashes include a tombstone trace — read up to 4KB
|
||||
var stack = ""
|
||||
try {
|
||||
exit.traceInputStream?.bufferedReader()?.use { stack = it.readText().take(4000) }
|
||||
} catch (_: Exception) {}
|
||||
|
||||
val ctx = mutableMapOf<String, Any?>(
|
||||
"device" to deviceInfo,
|
||||
"reason" to exit.reason,
|
||||
"process" to (exit.processName ?: ""),
|
||||
"crash_ts" to SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(exit.timestamp)),
|
||||
"note" to "Detected via ApplicationExitInfo on restart (API ${Build.VERSION.SDK_INT})"
|
||||
)
|
||||
reportAsync(type = reasonName, message = msg, stack = stack, context = ctx)
|
||||
|
||||
if (exit.timestamp > newestTs) newestTs = exit.timestamp
|
||||
}
|
||||
|
||||
if (newestTs > lastReportedTs) {
|
||||
prefs.edit().putLong(KEY_LAST_EXIT_TS, newestTs).apply()
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun fingerprint(type: String, message: String): String {
|
||||
val key = "$type:${message.take(120)}"
|
||||
return key.hashCode().toString(16)
|
||||
}
|
||||
|
||||
private fun reportAsync(type: String, message: String, stack: String, context: Map<String, Any?>, force: Boolean = false) {
|
||||
val fp = fingerprint(type, message)
|
||||
if (!force) {
|
||||
synchronized(sentFingerprints) {
|
||||
if (!sentFingerprints.add(fp)) return // already reported this session
|
||||
}
|
||||
} else {
|
||||
synchronized(sentFingerprints) { sentFingerprints.add(fp) }
|
||||
}
|
||||
executor.execute { doPost(type, message, stack, context) }
|
||||
}
|
||||
|
||||
/** Synchronous variant used only in the UncaughtExceptionHandler (already off main thread). */
|
||||
private fun reportSync(type: String, message: String, stack: String, context: Map<String, Any?>) {
|
||||
val fp = fingerprint(type, message)
|
||||
synchronized(sentFingerprints) { sentFingerprints.add(fp) }
|
||||
doPost(type, message, stack, context)
|
||||
}
|
||||
|
||||
// ── Crash persistence helpers ─────────────────────────────────────────────
|
||||
|
||||
private fun savePendingCrash(type: String, message: String, stack: String, context: Map<String, Any?>) {
|
||||
try {
|
||||
val ctxJson = JSONObject()
|
||||
context.forEach { (k, v) -> ctxJson.put(k, v) }
|
||||
val payload = JSONObject().apply {
|
||||
put("type", type)
|
||||
put("message", message)
|
||||
put("stack", stack)
|
||||
put("context", ctxJson)
|
||||
put("version", appVersion)
|
||||
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
|
||||
}
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_PENDING, payload.toString()).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun clearPendingCrash() {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().remove(KEY_PENDING).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at the start of [init]: if there is an unsent crash from the
|
||||
* previous session, send it now and then clear the entry.
|
||||
*/
|
||||
private fun sendPendingCrash() {
|
||||
val json = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_PENDING, null) ?: return
|
||||
// Clear immediately so we don't re-send if THIS launch also crashes
|
||||
clearPendingCrash()
|
||||
executor.execute {
|
||||
try {
|
||||
val p = JSONObject(json)
|
||||
val type = p.optString("type", "uncaught-exception")
|
||||
val message = p.optString("message", "")
|
||||
val stack = p.optString("stack", "")
|
||||
val savedTs = p.optString("ts", "")
|
||||
val ctxJson = p.optJSONObject("context") ?: JSONObject()
|
||||
val ctx = mutableMapOf<String, Any?>("note" to "Sent on next launch after crash")
|
||||
if (savedTs.isNotEmpty()) ctx["crash_ts"] = savedTs
|
||||
ctxJson.keys().forEach { k -> ctx[k] = ctxJson.opt(k) }
|
||||
doPost("$type-survived", message, stack, ctx)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun doPost(type: String, message: String, stack: String, context: Map<String, Any?>) {
|
||||
val url = serverBaseUrl.ifEmpty { return }
|
||||
val endpoint = "$url/api/?action=report_error"
|
||||
try {
|
||||
val ctxJson = JSONObject()
|
||||
context.forEach { (k, v) -> ctxJson.put(k, v) }
|
||||
|
||||
val payload = JSONObject().apply {
|
||||
put("source", "kiosk")
|
||||
put("type", type)
|
||||
put("message", message)
|
||||
put("stack", stack)
|
||||
put("context", ctxJson)
|
||||
put("version", appVersion)
|
||||
put("user_agent", "EverShelf-Kiosk/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})")
|
||||
put("url", url)
|
||||
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
|
||||
}
|
||||
|
||||
val conn = URL(endpoint).openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
conn.doOutput = true
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
|
||||
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
|
||||
val responseCode = conn.responseCode
|
||||
conn.disconnect()
|
||||
|
||||
Log.d(TAG, "Reported '$type' → HTTP $responseCode")
|
||||
} catch (e: Exception) {
|
||||
// Never rethrow from the error reporter itself
|
||||
Log.w(TAG, "Failed to report error '$type': ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package it.dadaloop.evershelf.kiosk
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import it.dadaloop.evershelf.kiosk.scale.GatewayService
|
||||
import java.net.URL
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var prefs: SharedPreferences
|
||||
private lateinit var urlEdit: EditText
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "evershelf_kiosk"
|
||||
private const val KEY_URL = "evershelf_url"
|
||||
private const val KEY_SETUP_COMPLETE = "setup_complete"
|
||||
private const val KEY_SCREENSAVER = "screensaver_enabled"
|
||||
private const val KEY_HAS_SCALE = "has_scale"
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
val lang = newBase.getSharedPreferences("evershelf_kiosk", Context.MODE_PRIVATE)
|
||||
.getString("kiosk_language", null)
|
||||
super.attachBaseContext(if (lang != null) SetupActivity.applyLocale(newBase, lang) else newBase)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_settings)
|
||||
|
||||
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
urlEdit = findViewById(R.id.urlEdit)
|
||||
|
||||
urlEdit.setText(prefs.getString(KEY_URL, "") ?: "")
|
||||
|
||||
// Screensaver toggle (default OFF = keep screen on)
|
||||
val switchScreensaver = findViewById<SwitchMaterial>(R.id.switchScreensaver)
|
||||
switchScreensaver.isChecked = prefs.getBoolean(KEY_SCREENSAVER, false)
|
||||
|
||||
// ── Smart Scale (BLE gateway service) ──────────────────────────────
|
||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
|
||||
val deviceName = prefs.getString("scale_device_name", null)
|
||||
val deviceAddr = prefs.getString("scale_device_address", null)
|
||||
val statusView = findViewById<TextView>(R.id.scaleGatewayStatus)
|
||||
val deviceView = findViewById<TextView>(R.id.scaleDeviceInfo)
|
||||
val btnScaleAction = findViewById<MaterialButton>(R.id.btnConfigureGateway)
|
||||
val btnReconfigureScale = findViewById<MaterialButton>(R.id.btnReconfigureScale)
|
||||
|
||||
when {
|
||||
!hasScale || deviceAddr == null -> {
|
||||
statusView.text = "Non configurata"
|
||||
statusView.setTextColor(0xFF94a3b8.toInt())
|
||||
deviceView.text = "Nessuna bilancia configurata — riesegui il wizard per aggiungerne una"
|
||||
btnScaleAction.visibility = android.view.View.VISIBLE
|
||||
btnScaleAction.text = "⚙️ Configura bilancia"
|
||||
btnScaleAction.setOnClickListener {
|
||||
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, false).apply()
|
||||
startActivity(Intent(this, SetupActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
statusView.text = "Configurata"
|
||||
statusView.setTextColor(0xFF34d399.toInt())
|
||||
deviceView.text = deviceName ?: deviceAddr
|
||||
btnScaleAction.visibility = android.view.View.VISIBLE
|
||||
btnScaleAction.text = "🔄 Riavvia servizio bilancia"
|
||||
btnScaleAction.setOnClickListener {
|
||||
GatewayService.stop(this)
|
||||
GatewayService.start(this)
|
||||
Toast.makeText(this, "Servizio bilancia riavviato", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
btnReconfigureScale.visibility = android.view.View.VISIBLE
|
||||
btnReconfigureScale.setOnClickListener {
|
||||
GatewayService.stop(this)
|
||||
prefs.edit()
|
||||
.remove("scale_device_address")
|
||||
.remove("scale_device_name")
|
||||
.putBoolean(KEY_HAS_SCALE, false)
|
||||
.putBoolean(KEY_SETUP_COMPLETE, false)
|
||||
.apply()
|
||||
val intent = Intent(this, SetupActivity::class.java)
|
||||
intent.putExtra("start_step", 4)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
}
|
||||
// Probe WebSocket port to show live status
|
||||
Thread {
|
||||
val running = try {
|
||||
java.net.Socket().use { s ->
|
||||
s.connect(java.net.InetSocketAddress("127.0.0.1", 8765), 1200); true
|
||||
}
|
||||
} catch (_: Exception) { false }
|
||||
runOnUiThread {
|
||||
if (running) {
|
||||
statusView.text = "Attivo ✅"
|
||||
statusView.setTextColor(0xFF34d399.toInt())
|
||||
deviceView.text = "${deviceName ?: "Bilancia"} — ws://127.0.0.1:8765"
|
||||
} else {
|
||||
statusView.text = "Non avviato ⚠️"
|
||||
statusView.setTextColor(0xFFfbbf24.toInt())
|
||||
deviceView.text = "${deviceName ?: "Bilancia"} — servizio non in esecuzione"
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
|
||||
// Back
|
||||
findViewById<android.widget.ImageButton>(R.id.btnBack).setOnClickListener { finish() }
|
||||
|
||||
// Test connection
|
||||
findViewById<MaterialButton>(R.id.btnTestConnection).setOnClickListener { testConnection() }
|
||||
|
||||
// Run wizard again
|
||||
findViewById<MaterialButton>(R.id.btnRunWizard).setOnClickListener {
|
||||
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, false).apply()
|
||||
startActivity(Intent(this, SetupActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
|
||||
// Save
|
||||
findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
|
||||
val url = urlEdit.text.toString().trim()
|
||||
if (url.isEmpty()) {
|
||||
Toast.makeText(this, "URL cannot be empty", Toast.LENGTH_SHORT).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
val screensaverOn = switchScreensaver.isChecked
|
||||
prefs.edit()
|
||||
.putString(KEY_URL, url)
|
||||
.putBoolean(KEY_SCREENSAVER, screensaverOn)
|
||||
.apply()
|
||||
// Screen always stays on in kiosk mode — no FLAG_KEEP_SCREEN_ON change needed here.
|
||||
// Push screensaver preference to the webapp so the in-app clock overlay is toggled.
|
||||
Thread {
|
||||
try {
|
||||
val apiUrl = "$url/api/index.php?action=save_settings"
|
||||
val body = "{\"screensaver_enabled\":$screensaverOn}"
|
||||
val conn = (java.net.URL(apiUrl).openConnection() as java.net.HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
connectTimeout = 5000
|
||||
readTimeout = 5000
|
||||
doOutput = true
|
||||
}
|
||||
conn.outputStream.use { it.write(body.toByteArray()) }
|
||||
conn.inputStream.close()
|
||||
conn.disconnect()
|
||||
} catch (_: Exception) {}
|
||||
}.start()
|
||||
Toast.makeText(this, "Impostazioni salvate", Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun testConnection() {
|
||||
val url = urlEdit.text.toString().trim()
|
||||
if (url.isEmpty()) {
|
||||
Toast.makeText(this, "Enter a URL first", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
Thread {
|
||||
try {
|
||||
val conn = URL(url).openConnection()
|
||||
|
||||
if (conn is HttpsURLConnection) {
|
||||
val trustAll = arrayOf<TrustManager>(object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
})
|
||||
val sc = SSLContext.getInstance("TLS")
|
||||
sc.init(null, trustAll, java.security.SecureRandom())
|
||||
conn.sslSocketFactory = sc.socketFactory
|
||||
conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
|
||||
}
|
||||
|
||||
conn.connectTimeout = 5000
|
||||
conn.readTimeout = 5000
|
||||
if (conn is java.net.HttpURLConnection) {
|
||||
conn.requestMethod = "GET"
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
runOnUiThread {
|
||||
if (code in 200..399) {
|
||||
Toast.makeText(this, "✓ Connection successful!", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(this, "⚠ Server responded: $code", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread {
|
||||
Toast.makeText(this, "✗ Cannot reach server", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package it.dadaloop.evershelf.kiosk.scale
|
||||
|
||||
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 = 20_000L
|
||||
private const val PREFS_NAME = "evershelf_kiosk"
|
||||
private const val PREF_SCALE_ADDRESS = "scale_device_address"
|
||||
private const val PREF_SCALE_NAME = "scale_device_name"
|
||||
|
||||
data class BleDeviceInfo(
|
||||
val device: BluetoothDevice,
|
||||
val name: String,
|
||||
val rssi: Int,
|
||||
val proximity: String,
|
||||
val scaleScore: Int,
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
private val pendingSubscriptions = ArrayDeque<BluetoothGattCharacteristic>()
|
||||
|
||||
val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty()
|
||||
|
||||
fun getSavedDeviceAddress(): String? =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(PREF_SCALE_ADDRESS, null)
|
||||
|
||||
fun getSavedDeviceName(): String? =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(PREF_SCALE_NAME, null)
|
||||
|
||||
fun saveDevice(address: String, name: String) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(PREF_SCALE_ADDRESS, address)
|
||||
.putString(PREF_SCALE_NAME, name)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun clearSavedDevice() {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(PREF_SCALE_ADDRESS)
|
||||
.remove(PREF_SCALE_NAME)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun enableAutoConnect() {
|
||||
autoConnectAddress = getSavedDeviceAddress()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
fun startScan() {
|
||||
val adapter = bluetoothAdapter ?: run { listener.onError("Bluetooth non disponibile"); return }
|
||||
if (!adapter.isEnabled) { listener.onError("Bluetooth disabilitato"); return }
|
||||
if (isScanning) stopScan()
|
||||
leScanner = adapter.bluetoothLeScanner
|
||||
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
|
||||
isScanning = true
|
||||
try { leScanner?.startScan(null, settings, scanCallback) }
|
||||
catch (_: Exception) { leScanner?.startScan(scanCallback) }
|
||||
mainHandler.postDelayed({ stopScan(); listener.onScanStopped() }, SCAN_PERIOD_MS)
|
||||
}
|
||||
|
||||
fun stopScan() {
|
||||
if (!isScanning) return
|
||||
isScanning = false
|
||||
try { leScanner?.stopScan(scanCallback) } catch (_: Exception) {}
|
||||
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() }
|
||||
?: try { device.name?.takeIf { it.isNotBlank() } } catch (_: SecurityException) { null }
|
||||
?: return // skip unnamed devices
|
||||
val score = scoreLikelyScale(name, result.scanRecord)
|
||||
val info = BleDeviceInfo(device, name, result.rssi, rssiToProximity(result.rssi), score)
|
||||
mainHandler.post { listener.onDeviceFound(info) }
|
||||
if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) {
|
||||
autoConnectAddress = null
|
||||
mainHandler.post { connect(device) }
|
||||
}
|
||||
}
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
isScanning = false
|
||||
mainHandler.post { listener.onError("BLE scan failed (code $errorCode)") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun rssiToProximity(rssi: Int) = when {
|
||||
rssi >= -60 -> "📶 Vicino"
|
||||
rssi >= -80 -> "📶 Medio"
|
||||
else -> "📶 Lontano"
|
||||
}
|
||||
|
||||
private fun scoreLikelyScale(name: String, scanRecord: ScanRecord?): Int {
|
||||
var score = 0
|
||||
val lower = name.lowercase()
|
||||
val foodKeywords = listOf("scale","bilancia","kitchen","food","cucina","coffee","caffe",
|
||||
"balance","weight","waage","arboleaf","ck10","ck20","ek-","acaia","felicita",
|
||||
"timemore","brewista","hario","ozeri","etekcity","nutri","nicewell","koios","renpho")
|
||||
if (foodKeywords.any { lower.contains(it) }) score += 10
|
||||
val bodyKeywords = listOf("body","fat","bmi","composition","fitness","mi body","lepulse")
|
||||
if (bodyKeywords.any { lower.contains(it) }) score -= 5
|
||||
scanRecord?.serviceUuids?.let { uuids ->
|
||||
val us = uuids.map { it.uuid.toString().lowercase() }
|
||||
if (us.any { it.startsWith("0000181d") }) score += 15
|
||||
if (us.any { it.startsWith("0000ffe0") || it.startsWith("0000fff0") }) score += 10
|
||||
if (us.any { it.startsWith("49535343") }) score += 20
|
||||
if (us.any { it.startsWith("0000181b") }) score -= 10
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
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("Permesso mancante: ${e.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
pendingSubscriptions.clear()
|
||||
try { gatt?.disconnect(); gatt?.close() } catch (_: Exception) {}
|
||||
gatt = null
|
||||
connectedDeviceName = ""
|
||||
}
|
||||
|
||||
private val gattCallback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
mainHandler.postDelayed({ gatt.discoverServices() }, 500)
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
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") }
|
||||
return
|
||||
}
|
||||
val targetChars = mutableListOf<BluetoothGattCharacteristic>()
|
||||
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)?.let { targetChars.add(it) }
|
||||
gatt.getService(BleUuids.FFE0)?.getCharacteristic(BleUuids.FFE1)?.let { targetChars.add(it) }
|
||||
gatt.getService(BleUuids.FFF0)?.let { svc ->
|
||||
svc.getCharacteristic(BleUuids.FFF4)?.let { targetChars.add(it) }
|
||||
?: svc.getCharacteristic(BleUuids.FFF1)?.let { targetChars.add(it) }
|
||||
}
|
||||
gatt.getService(BleUuids.ACAIA_SERVICE)?.getCharacteristic(BleUuids.ACAIA_CHAR)?.let { targetChars.add(it) }
|
||||
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("Nessuna caratteristica peso trovata") }
|
||||
return
|
||||
}
|
||||
gatt.getService(BleUuids.BATTERY_SERVICE)?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)?.let { targetChars.add(it) }
|
||||
try { gatt.device?.address?.let { saveDevice(it, connectedDeviceName) } } catch (_: SecurityException) {}
|
||||
pendingSubscriptions.clear()
|
||||
pendingSubscriptions.addAll(targetChars)
|
||||
val deviceName = try { gatt.device?.name ?: "Scale" } catch (_: SecurityException) { "Scale" }
|
||||
connectedDeviceName = deviceName
|
||||
mainHandler.post { listener.onConnected(deviceName) }
|
||||
subscribeNext(gatt)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
subscribeNext(gatt)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
processCharacteristicData(characteristic, characteristic.value ?: return)
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeNext(gatt: BluetoothGatt) {
|
||||
val char = pendingSubscriptions.removeFirstOrNull() ?: return
|
||||
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
|
||||
try { gatt.readCharacteristic(char) } catch (_: SecurityException) {}
|
||||
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 { 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 (_: SecurityException) {}
|
||||
}
|
||||
|
||||
private fun processCharacteristicData(char: BluetoothGattCharacteristic, data: ByteArray) {
|
||||
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR && data.isNotEmpty()) {
|
||||
val level = data[0].toInt() and 0xFF
|
||||
mainHandler.post { listener.onBatteryReceived(level) }
|
||||
return
|
||||
}
|
||||
val reading = ScaleProtocol.parse(char, data) { msg -> mainHandler.post { listener.onDebugEvent(msg) } }
|
||||
if (reading != null && reading.value > 0f) {
|
||||
mainHandler.post { listener.onWeightReceived(reading) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package it.dadaloop.evershelf.kiosk.scale
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import it.dadaloop.evershelf.kiosk.KioskActivity
|
||||
import it.dadaloop.evershelf.kiosk.R
|
||||
|
||||
private const val TAG = "GatewayService"
|
||||
private const val WS_PORT = 8765
|
||||
private const val NOTIF_ID = 1001
|
||||
private const val CHANNEL_ID = "evershelf_gateway"
|
||||
private const val RECONNECT_DELAY_MS = 8_000L
|
||||
|
||||
/**
|
||||
* Foreground service that keeps the BLE scale connection and WebSocket server alive
|
||||
* independently of the KioskActivity lifecycle.
|
||||
*
|
||||
* The WebSocket server on port 8765 is protocol-compatible with the standalone
|
||||
* evershelf-scale-gateway app, so the EverShelf webapp JS needs no changes.
|
||||
*/
|
||||
class GatewayService : Service(), BleScaleListener, ServerEventListener {
|
||||
|
||||
private lateinit var bleManager: BleScaleManager
|
||||
private var wsServer: GatewayWebSocketServer? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var connectedDeviceName: String? = null
|
||||
private var batteryLevel: Int? = null
|
||||
private var reconnectPending = false
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "evershelf.gateway.START"
|
||||
const val ACTION_STOP = "evershelf.gateway.STOP"
|
||||
|
||||
/** Returns true if the service can try to connect (BLE permissions ok, device saved). */
|
||||
fun canStart(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences("evershelf_kiosk", Context.MODE_PRIVATE)
|
||||
val hasScale = prefs.getBoolean("has_scale", false)
|
||||
val hasDevice = prefs.getString("scale_device_address", null) != null
|
||||
return hasScale && hasDevice
|
||||
}
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, GatewayService::class.java).apply {
|
||||
action = ACTION_START
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.startService(Intent(context, GatewayService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
bleManager = BleScaleManager(this, this)
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIF_ID, buildNotification("Avvio bilancia…"))
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
else -> {
|
||||
startWsServer()
|
||||
connectToSavedScale()
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
bleManager.disconnect()
|
||||
try { wsServer?.stop(1000) } catch (_: Exception) {}
|
||||
wsServer = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ── WebSocket server ──────────────────────────────────────────────────────
|
||||
|
||||
private fun startWsServer() {
|
||||
if (wsServer != null) return
|
||||
try {
|
||||
wsServer = GatewayWebSocketServer(WS_PORT, this)
|
||||
wsServer!!.isReuseAddr = true
|
||||
wsServer!!.start()
|
||||
Log.i(TAG, "WebSocket server started on :$WS_PORT")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start WebSocket server", e)
|
||||
updateNotification("⚠️ WebSocket non avviato: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// ── BLE connection ────────────────────────────────────────────────────────
|
||||
|
||||
private fun connectToSavedScale() {
|
||||
if (!bleManager.hasRequiredPermissions()) {
|
||||
updateNotification("⚠️ Permessi Bluetooth mancanti")
|
||||
return
|
||||
}
|
||||
val addr = bleManager.getSavedDeviceAddress() ?: run {
|
||||
updateNotification("Nessuna bilancia configurata")
|
||||
return
|
||||
}
|
||||
val name = bleManager.getSavedDeviceName() ?: addr
|
||||
updateNotification("🔍 Connessione a $name…")
|
||||
// Enable auto-connect: the scan callback will connect when the saved device is found
|
||||
bleManager.enableAutoConnect()
|
||||
bleManager.startScan()
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectPending) return
|
||||
reconnectPending = true
|
||||
handler.postDelayed({
|
||||
reconnectPending = false
|
||||
if (bleManager.getSavedDeviceAddress() != null) {
|
||||
updateNotification("🔄 Riconnessione bilancia…")
|
||||
bleManager.enableAutoConnect()
|
||||
bleManager.startScan()
|
||||
}
|
||||
}, RECONNECT_DELAY_MS)
|
||||
}
|
||||
|
||||
// ── BleScaleListener ─────────────────────────────────────────────────────
|
||||
|
||||
override fun onDeviceFound(info: BleDeviceInfo) { /* handled by autoConnect */ }
|
||||
|
||||
override fun onConnecting(device: BluetoothDevice) {
|
||||
val name = try { device.name ?: device.address } catch (_: SecurityException) { device.address }
|
||||
updateNotification("⏳ Connessione a $name…")
|
||||
}
|
||||
|
||||
override fun onConnected(deviceName: String) {
|
||||
connectedDeviceName = deviceName
|
||||
updateNotification("✅ $deviceName connessa")
|
||||
wsServer?.publishStatus("connected", deviceName, batteryLevel)
|
||||
Log.i(TAG, "BLE scale connected: $deviceName")
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
val name = connectedDeviceName ?: "bilancia"
|
||||
connectedDeviceName = null
|
||||
updateNotification("⚠️ $name disconnessa — riconnessione…")
|
||||
wsServer?.publishStatus("disconnected", null, null)
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onWeightReceived(reading: WeightReading) {
|
||||
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, batteryLevel)
|
||||
}
|
||||
|
||||
override fun onBatteryReceived(level: Int) {
|
||||
batteryLevel = level
|
||||
connectedDeviceName?.let { wsServer?.publishStatus("connected", it, level) }
|
||||
}
|
||||
|
||||
override fun onError(message: String) {
|
||||
Log.w(TAG, "BLE error: $message")
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onScanStopped() {
|
||||
// If not connected yet, schedule a retry so we keep searching after the scale powers on
|
||||
if (!bleManager.isConnected) scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onDebugEvent(message: String) {
|
||||
Log.d(TAG, message)
|
||||
}
|
||||
|
||||
// ── ServerEventListener ───────────────────────────────────────────────────
|
||||
|
||||
override fun onClientConnected(address: String) {
|
||||
Log.d(TAG, "WS client connected: $address")
|
||||
}
|
||||
|
||||
override fun onClientDisconnected(address: String) {
|
||||
Log.d(TAG, "WS client disconnected: $address")
|
||||
}
|
||||
|
||||
override fun onClientRequestedWeight() { /* weight is pushed via onWeightReceived */ }
|
||||
|
||||
// ── Notification ──────────────────────────────────────────────────────────
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"EverShelf Scale Gateway",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Bilancia smart integrata"
|
||||
setShowBadge(false)
|
||||
}
|
||||
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(text: String): Notification {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, KioskActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, CHANNEL_ID)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(this)
|
||||
}
|
||||
return builder
|
||||
.setContentTitle("EverShelf Scale")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification(text: String) {
|
||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(NOTIF_ID, buildNotification(text))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package it.dadaloop.evershelf.kiosk.scale
|
||||
|
||||
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"
|
||||
|
||||
interface ServerEventListener {
|
||||
fun onClientConnected(address: String)
|
||||
fun onClientDisconnected(address: String)
|
||||
fun onClientRequestedWeight()
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket server that exposes BLE scale data to EverShelf running in a browser.
|
||||
* Protocol is identical to the standalone gateway app so the webapp JS needs no changes.
|
||||
*/
|
||||
class GatewayWebSocketServer(
|
||||
port: Int,
|
||||
private val eventListener: ServerEventListener?,
|
||||
) : WebSocketServer(InetSocketAddress(port)) {
|
||||
|
||||
private val pendingWeightRequests: MutableSet<WebSocket> =
|
||||
Collections.synchronizedSet(mutableSetOf())
|
||||
|
||||
@Volatile private var lastStatusJson: String = buildStatusJson("disconnected", null, null)
|
||||
@Volatile private var lastWeightJson: String? = null
|
||||
|
||||
override fun onStart() {
|
||||
Log.i(TAG, "WebSocket server started on port ${address.port}")
|
||||
connectionLostTimeout = 30
|
||||
}
|
||||
|
||||
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
|
||||
conn.send(lastStatusJson)
|
||||
lastWeightJson?.let { conn.send(it) }
|
||||
eventListener?.onClientConnected(conn.remoteSocketAddress?.toString() ?: "?")
|
||||
}
|
||||
|
||||
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
|
||||
pendingWeightRequests.remove(conn)
|
||||
eventListener?.onClientDisconnected(conn.remoteSocketAddress?.toString() ?: "?")
|
||||
}
|
||||
|
||||
override fun onMessage(conn: WebSocket, message: String) {
|
||||
try {
|
||||
when (JSONObject(message).optString("type")) {
|
||||
"ping" -> conn.send("""{"type":"pong"}""")
|
||||
"get_status" -> conn.send(lastStatusJson)
|
||||
"get_weight" -> {
|
||||
pendingWeightRequests.add(conn)
|
||||
eventListener?.onClientRequestedWeight()
|
||||
lastWeightJson?.let { conn.send(it) }
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
override fun onError(conn: WebSocket?, ex: Exception) {
|
||||
Log.e(TAG, "WebSocket error", ex)
|
||||
}
|
||||
|
||||
fun publishStatus(state: String, deviceName: String?, battery: Int?) {
|
||||
lastStatusJson = buildStatusJson(state, deviceName, battery)
|
||||
broadcast(lastStatusJson)
|
||||
}
|
||||
|
||||
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) { pendingWeightRequests.clear() }
|
||||
}
|
||||
|
||||
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")
|
||||
obj.put("value", Math.round(value * 10f) / 10.0)
|
||||
obj.put("unit", unit)
|
||||
obj.put("stable", stable)
|
||||
obj.put("timestamp", System.currentTimeMillis())
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package it.dadaloop.evershelf.kiosk.scale
|
||||
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import java.util.UUID
|
||||
|
||||
// ── Data model ────────────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
|
||||
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
|
||||
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
|
||||
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")
|
||||
val ACAIA_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
|
||||
val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
|
||||
val QN_AE00 = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
|
||||
val QN_AE02 = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb")
|
||||
}
|
||||
|
||||
// ── Scale protocol parser ─────────────────────────────────────────────────────
|
||||
|
||||
object ScaleProtocol {
|
||||
|
||||
private const val MAX_GRAMS = 15000f
|
||||
private const val MIN_GRAMS = 0.5f
|
||||
|
||||
fun resetState() { /* reserved */ }
|
||||
|
||||
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
|
||||
}
|
||||
when (char.uuid) {
|
||||
BleUuids.WEIGHT_MEASUREMENT_CHAR -> return parseSigWeight(data, debug)
|
||||
}
|
||||
if (data.size == 18
|
||||
&& (data[0].toInt() and 0xFF) == 0x10
|
||||
&& (data[1].toInt() and 0xFF) == 0x12) {
|
||||
return parseQNFood(data, debug)
|
||||
}
|
||||
return parseGeneric(data, debug)
|
||||
}
|
||||
|
||||
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
|
||||
debug?.invoke("SIG 2A9D: raw=$raw -> ${g}g")
|
||||
if (g < MIN_GRAMS || g > MAX_GRAMS) null
|
||||
else WeightReading(g, "g", stable = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
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")
|
||||
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"; 0x04 -> "ml"; else -> "g"
|
||||
}
|
||||
val value = rawValue / 10f
|
||||
debug?.invoke("QN-KS: ${value}${unit} stable=$stable")
|
||||
if (rawValue == 0) return null
|
||||
val valueG = if (unit == "oz") value * 28.3495f else value
|
||||
if (valueG < MIN_GRAMS || valueG > MAX_GRAMS) return null
|
||||
return WeightReading(value, unit, stable)
|
||||
}
|
||||
|
||||
private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
if (data.size < 3) return null
|
||||
data class C(val pos: Int, val be: Boolean, val div: Float, val label: String)
|
||||
val candidates = listOf(
|
||||
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"),
|
||||
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"),
|
||||
C(1, false, 2f, "pos1 LE 0.5g"), C(1, true, 2f, "pos1 BE 0.5g"),
|
||||
C(1, false, 0.1f, "pos1 LE cg"), C(1, true, 0.1f, "pos1 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")
|
||||
return WeightReading(g, "g", stable = false)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun u16le(data: ByteArray, offset: Int) =
|
||||
(data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
private fun u16be(data: ByteArray, offset: Int) =
|
||||
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
||||
}
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 386 KiB |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#1e293b" />
|
||||
<corners android:radius="16dp" />
|
||||
<stroke android:width="1dp" android:color="#334155" />
|
||||
</shape>
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- House outline -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,24 L26,46 L26,80 L82,80 L82,46 Z" />
|
||||
<path
|
||||
android:fillColor="#059669"
|
||||
android:pathData="M54,28 L30,48 L30,76 L78,76 L78,48 Z" />
|
||||
|
||||
<!-- Door -->
|
||||
<path
|
||||
android:fillColor="#065F46"
|
||||
android:pathData="M48,76 L48,58 L60,58 L60,76 Z" />
|
||||
|
||||
<!-- Shelves inside -->
|
||||
<path
|
||||
android:fillColor="#34D399"
|
||||
android:pathData="M34,52 L46,52 L46,54 L34,54 Z" />
|
||||
<path
|
||||
android:fillColor="#34D399"
|
||||
android:pathData="M62,52 L74,52 L74,54 L62,54 Z" />
|
||||
<path
|
||||
android:fillColor="#34D399"
|
||||
android:pathData="M34,60 L46,60 L46,62 L34,62 Z" />
|
||||
<path
|
||||
android:fillColor="#34D399"
|
||||
android:pathData="M62,60 L74,60 L74,62 L62,62 Z" />
|
||||
|
||||
<!-- Items on shelves (small boxes/jars) -->
|
||||
<path
|
||||
android:fillColor="#A7F3D0"
|
||||
android:pathData="M36,48 L40,48 L40,52 L36,52 Z" />
|
||||
<path
|
||||
android:fillColor="#6EE7B7"
|
||||
android:pathData="M42,49 L45,49 L45,52 L42,52 Z" />
|
||||
<path
|
||||
android:fillColor="#A7F3D0"
|
||||
android:pathData="M64,48 L68,48 L68,52 L64,52 Z" />
|
||||
<path
|
||||
android:fillColor="#6EE7B7"
|
||||
android:pathData="M70,49 L73,49 L73,52 L70,52 Z" />
|
||||
<path
|
||||
android:fillColor="#A7F3D0"
|
||||
android:pathData="M36,56 L40,56 L40,60 L36,60 Z" />
|
||||
<path
|
||||
android:fillColor="#6EE7B7"
|
||||
android:pathData="M42,57 L45,57 L45,60 L42,60 Z" />
|
||||
<path
|
||||
android:fillColor="#A7F3D0"
|
||||
android:pathData="M64,56 L68,56 L68,60 L64,60 Z" />
|
||||
<path
|
||||
android:fillColor="#6EE7B7"
|
||||
android:pathData="M70,57 L73,57 L73,60 L70,60 Z" />
|
||||
|
||||
<!-- Chimney -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M68,30 L74,30 L74,42 L68,42 Z" />
|
||||
<path
|
||||
android:fillColor="#059669"
|
||||
android:pathData="M69,31 L73,31 L73,42 L69,42 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#1e293b" />
|
||||
<corners android:radius="12dp" />
|
||||
<stroke android:width="1dp" android:color="#334155" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#0f1729" />
|
||||
<corners android:radius="10dp" />
|
||||
<stroke android:width="1dp" android:color="#1e293b" />
|
||||
</shape>
|
||||
@@ -0,0 +1,155 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#0f172a">
|
||||
|
||||
<!-- Splash screen (shown briefly on launch if setup is already done) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/splashContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:visibility="visible">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="260dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@drawable/ic_logo"
|
||||
android:adjustViewBounds="true"
|
||||
android:scaleType="fitCenter"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:contentDescription="EverShelf" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="La tua dispensa smart"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="16sp"
|
||||
android:layout_marginBottom="48dp" />
|
||||
|
||||
<ProgressBar
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:indeterminateTint="#7c3aed" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- WebView (shown after setup) -->
|
||||
<WebView
|
||||
android:id="@+id/webView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Settings gear (shown after setup, over WebView) — top-right corner to avoid overlapping modals -->
|
||||
<ImageButton
|
||||
android:id="@+id/btnSettings"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:src="@android:drawable/ic_menu_manage"
|
||||
android:alpha="0.12"
|
||||
android:contentDescription="Settings"
|
||||
android:scaleType="centerInside"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- 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:layout_gravity="top"
|
||||
android:orientation="vertical"
|
||||
android:background="#1e293b"
|
||||
android:visibility="gone">
|
||||
|
||||
<!-- Banner row: message + buttons -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Thin progress bar at the bottom of the banner -->
|
||||
<ProgressBar
|
||||
android:id="@+id/bannerProgressBar"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="4dp"
|
||||
android:progressTint="#7c3aed"
|
||||
android:progressBackgroundTint="#334155"
|
||||
android:max="100"
|
||||
android:progress="0"
|
||||
android:indeterminate="false"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Download progress (used by download progress poll) -->
|
||||
<ProgressBar
|
||||
android:id="@+id/downloadProgressBar"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="4dp"
|
||||
android:layout_gravity="top"
|
||||
android:layout_marginTop="56dp"
|
||||
android:progressTint="#7c3aed"
|
||||
android:progressBackgroundTint="#334155"
|
||||
android:max="100"
|
||||
android:progress="0"
|
||||
android:indeterminate="false"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/downloadProgressText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|center_horizontal"
|
||||
android:layout_marginTop="64dp"
|
||||
android:text=""
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,276 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#0f172a"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<!-- Header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="32dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/card_background"
|
||||
android:src="@android:drawable/ic_menu_revert"
|
||||
android:scaleType="centerInside"
|
||||
android:contentDescription="Back"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Settings"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Server URL Section -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="SERVER CONNECTION"
|
||||
android:textColor="#7c3aed"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.1"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="EverShelf URL"
|
||||
android:textColor="#cbd5e1"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/urlEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textUri"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textColorHint="#475569"
|
||||
android:hint="https://192.168.1.100/dispensa"
|
||||
android:background="@drawable/input_background"
|
||||
android:padding="14dp"
|
||||
android:textSize="15sp"
|
||||
android:singleLine="true"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnTestConnection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:text="Test Connection"
|
||||
android:textSize="13sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:strokeColor="#334155"
|
||||
android:textColor="#94a3b8" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Scale Section -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="BILANCIA SMART"
|
||||
android:textColor="#7c3aed"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.1"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Servizio bilancia BLE"
|
||||
android:textColor="#cbd5e1"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/scaleGatewayStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Active"
|
||||
android:textColor="#34d399"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/scaleDeviceInfo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="No scale connected"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnConfigureGateway"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:text="⚙️ Configura bilancia"
|
||||
android:textSize="13sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:strokeColor="#34d399"
|
||||
android:textColor="#34d399"
|
||||
android:layout_marginTop="12dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnReconfigureScale"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:text="🔄 Riconfigura bilancia"
|
||||
android:textSize="13sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:strokeColor="#fbbf24"
|
||||
android:textColor="#fbbf24"
|
||||
android:layout_marginTop="8dp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Screensaver Section -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="SCHERMO"
|
||||
android:textColor="#7c3aed"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.1"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Salvaschermo"
|
||||
android:textColor="#cbd5e1"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Oscura lo schermo dopo inattività (default: off)"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginTop="2dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switchScreensaver"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="RESET"
|
||||
android:textColor="#ef4444"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.1"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnRunWizard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:text="Run Setup Wizard Again"
|
||||
android:textSize="14sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:strokeColor="#334155"
|
||||
android:textColor="#94a3b8" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Spacer -->
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<!-- Buttons -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnSave"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:text="Save Changes"
|
||||
android:textSize="16sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#7c3aed"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -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="@color/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="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 147 B |
|
After Width: | Height: | Size: 147 B |
|
After Width: | Height: | Size: 115 B |
|
After Width: | Height: | Size: 115 B |
|
After Width: | Height: | Size: 199 B |
|
After Width: | Height: | Size: 199 B |
|
After Width: | Height: | Size: 287 B |
|
After Width: | Height: | Size: 287 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 413 B |
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
|
||||
<!-- Setup-Assistent Zeichenfolgen -->
|
||||
<string name="setup_enter_url">Bitte zuerst eine URL eingeben</string>
|
||||
<string name="setup_testing">Verbindung wird getestet…</string>
|
||||
<string name="setup_server_found">EverShelf-Server gefunden und API aktiv!</string>
|
||||
<string name="setup_api_not_found">Server erreichbar, aber EverShelf-API nicht gefunden. Pfad prüfen.</string>
|
||||
<string name="setup_unreachable">Server nicht erreichbar</string>
|
||||
<string name="setup_discover_btn">🔍 Lokales Netzwerk durchsuchen</string> <string name="setup_perms_granted_next">✅ Berechtigungen erteilt — Weiter →</string> <string name="setup_discovering">Suche läuft…</string>
|
||||
<string name="setup_discovering_detail">Suche nach EverShelf-Servern im lokalen Netzwerk…</string>
|
||||
<string name="setup_discover_not_found">Kein EverShelf-Server automatisch gefunden. URL manuell eingeben.</string>
|
||||
<string name="setup_exit_title">Setup beenden?</string>
|
||||
<string name="setup_exit_message">Die Einrichtung kann später beim erneuten Öffnen der App abgeschlossen werden.</string>
|
||||
<string name="setup_exit_confirm">Beenden</string>
|
||||
<string name="setup_exit_cancel">Weiter</string>
|
||||
|
||||
<!-- Wizard Schritt 3: Smart-Waage -->
|
||||
<string name="wizard_step3_title">Smart-Waage (Optional)</string>
|
||||
<string name="wizard_step3_description">Um eine Bluetooth-Küchenwaage zu verwenden, musst du die EverShelf Scale Gateway App separat installieren.</string>
|
||||
<string name="wizard_step3_question">Hast du eine Bluetooth-Küchenwaage?</string>
|
||||
<string name="wizard_step3_yes">✅ Ja, ich habe eine Waage</string>
|
||||
<string name="wizard_step3_no">➡️ Nein, überspringen</string>
|
||||
|
||||
<!-- Gateway-Statusmeldungen -->
|
||||
<string name="wizard_gateway_installed">Scale Gateway installiert ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">Wird beim Fortfahren im Hintergrund gestartet.</string>
|
||||
<string name="wizard_gateway_not_installed">Scale Gateway nicht installiert</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Installiere die Scale Gateway App, um eine Bluetooth-Waage zu nutzen.</string>
|
||||
<string name="wizard_gateway_checking">Prüfe auf Updates…</string>
|
||||
<string name="wizard_gateway_up_to_date">Scale Gateway ist aktuell.</string>
|
||||
<string name="wizard_gateway_update_available">Update für Scale Gateway verfügbar</string>
|
||||
<string name="wizard_gateway_update_detail">Tippe auf den Button, um jetzt zu aktualisieren.</string>
|
||||
|
||||
<!-- Download- / Installationsfortschritt -->
|
||||
<string name="install_downloading">Download läuft…</string>
|
||||
<string name="install_downloading_detail">Bitte warten, die Datei wird heruntergeladen.</string>
|
||||
<string name="install_installing">Installation läuft…</string>
|
||||
<string name="install_confirm_detail">Bestätige die Installation im geöffneten Dialog.</string>
|
||||
<string name="install_success">Erfolgreich installiert!</string>
|
||||
<string name="install_success_detail">Die App wurde aktualisiert.</string>
|
||||
<string name="install_error_download">Download fehlgeschlagen</string>
|
||||
<string name="install_error_download_detail">Verbindung prüfen und erneut versuchen.</string>
|
||||
<string name="install_error_install">Installation fehlgeschlagen</string>
|
||||
<string name="install_perm_detail">Aktiviere \'Unbekannte Apps installieren\' in den Einstellungen, dann komm zurück.</string>
|
||||
<string name="install_btn_retry">↩ Nochmal versuchen</string>
|
||||
|
||||
<!-- Schaltflächen -->
|
||||
<string name="btn_back">Zurück</string>
|
||||
<string name="btn_launch">🚀 EverShelf starten</string>
|
||||
<string name="btn_launch_no_scale">🚀 Ohne Waage starten</string>
|
||||
<string name="btn_download_gateway">📥 Scale Gateway installieren</string>
|
||||
<string name="btn_update_gateway">📥 Scale Gateway aktualisieren</string>
|
||||
|
||||
<!-- Server-Erreichbarkeit prüfen (Wizard Schritt 3) -->
|
||||
<string name="wizard_server_checking">Server-Verbindung wird geprüft…</string>
|
||||
<string name="wizard_server_ok">Server erreichbar ✅</string>
|
||||
<string name="wizard_server_ok_detail">Fehlerberichterstattung aktiv — Installationsfehler werden automatisch an GitHub Issues gesendet.</string>
|
||||
<string name="wizard_server_error">Server nicht erreichbar ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Fehler werden GitHub Issues nicht erreichen. URL in Schritt 2 prüfen.</string>
|
||||
<!-- Bildschirmschoner-Schritt -->
|
||||
<string name="setup_screensaver_title">Bildschirmschoner</string>
|
||||
<string name="setup_screensaver_desc">Zeigt nach 5 Minuten Inaktivität eine Uhr mit nützlichen Fakten. Standardmäßig deaktiviert (Bildschirm bleibt immer an).</string>
|
||||
<string name="setup_screensaver_toggle_label">Bildschirmschoner aktivieren</string>
|
||||
<string name="setup_screensaver_toggle_hint">Wenn deaktiviert, bleibt der Bildschirm immer an.</string>
|
||||
|
||||
<!-- Zusammenfassung -->
|
||||
<string name="summary_lang">Sprache</string>
|
||||
<string name="summary_scale_skip">Waage: nicht konfiguriert</string>
|
||||
<string name="summary_screensaver_on">Bildschirmschoner: aktiv</string>
|
||||
<string name="summary_screensaver_off">Bildschirm immer an (Bildschirmschoner deaktiviert)</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
|
||||
<!-- Stringhe setup wizard -->
|
||||
<string name="setup_enter_url">Inserisci prima un URL</string>
|
||||
<string name="setup_testing">Verifica connessione…</string>
|
||||
<string name="setup_server_found">Server EverShelf trovato e API attiva!</string>
|
||||
<string name="setup_api_not_found">Server raggiungibile ma API EverShelf non trovata. Verifica il percorso.</string>
|
||||
<string name="setup_unreachable">Impossibile raggiungere il server</string>
|
||||
<string name="setup_discover_btn">🔍 Cerca nella rete locale</string> <string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string> <string name="setup_discovering">Scansione in corso…</string>
|
||||
<string name="setup_discovering_detail">Ricerca server EverShelf nella rete locale…</string>
|
||||
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l\'URL manualmente.</string>
|
||||
<string name="setup_exit_title">Uscire dalla configurazione?</string>
|
||||
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l\'app.</string>
|
||||
<string name="setup_exit_confirm">Esci</string>
|
||||
<string name="setup_exit_cancel">Continua</string>
|
||||
|
||||
<!-- Wizard Step 3: Bilancia smart -->
|
||||
<string name="wizard_step3_title">Bilancia Smart</string>
|
||||
<string name="wizard_step3_description">EverShelf Kiosk include un gateway Bluetooth integrato — nessuna app esterna necessaria. Seleziona la tua bilancia qui sotto.</string>
|
||||
<string name="wizard_step3_question">Hai una bilancia smart Bluetooth?</string>
|
||||
<string name="wizard_step3_yes">✅ Sì, ho una bilancia</string>
|
||||
<string name="wizard_step3_no">➡️ No, salta questo passaggio</string>
|
||||
|
||||
<!-- Messaggi stato gateway -->
|
||||
<string name="wizard_gateway_installed">Bilancia salvata ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all\'avvio.</string>
|
||||
<string name="wizard_gateway_not_installed">Nessuna bilancia selezionata</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Scansiona le bilance BLE nelle vicinanze e tocca una per selezionarla.</string>
|
||||
<string name="wizard_gateway_checking">Scansione bilance BLE in corso…</string>
|
||||
<string name="wizard_gateway_up_to_date">Servizio BLE bilancia pronto.</string>
|
||||
<string name="wizard_gateway_update_available">Bilancia BLE trovata</string>
|
||||
<string name="wizard_gateway_update_detail">Tocca la bilancia nell\'elenco per connettersi.</string>
|
||||
|
||||
<!-- Stati scaricamento / installazione -->
|
||||
<string name="install_downloading">Scaricamento in corso…</string>
|
||||
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
|
||||
<string name="install_installing">Installazione in corso…</string>
|
||||
<string name="install_confirm_detail">Conferma l\'installazione nel dialog che si è aperto.</string>
|
||||
<string name="install_success">Installato con successo!</string>
|
||||
<string name="install_success_detail">L\'app è stata aggiornata.</string>
|
||||
<string name="install_error_download">Download fallito</string>
|
||||
<string name="install_error_download_detail">Controlla la connessione e riprova.</string>
|
||||
<string name="install_error_install">Installazione fallita</string>
|
||||
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
|
||||
<string name="install_btn_retry">↩ Riprova</string>
|
||||
|
||||
<!-- Pulsanti -->
|
||||
<string name="btn_back">Indietro</string>
|
||||
<string name="btn_launch">🚀 Avvia EverShelf</string>
|
||||
<string name="btn_launch_no_scale">🚀 Avvia senza bilancia</string>
|
||||
<string name="btn_download_gateway">📥 Installa Scale Gateway</string>
|
||||
<string name="btn_update_gateway">📥 Aggiorna Scale Gateway</string>
|
||||
|
||||
<!-- Verifica raggiungibilità server (step 3 wizard) -->
|
||||
<string name="wizard_server_checking">Verifica connessione server…</string>
|
||||
<string name="wizard_server_ok">Server raggiungibile ✅</string>
|
||||
<string name="wizard_server_ok_detail">Segnalazione errori attiva — i problemi di installazione vengono inviati automaticamente alle GitHub Issues.</string>
|
||||
<string name="wizard_server_error">Server non raggiungibile ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l\'URL inserito al passaggio 2.</string>
|
||||
<!-- Passo salvaschermo -->
|
||||
<string name="setup_screensaver_title">Salvaschermo</string>
|
||||
<string name="setup_screensaver_desc">Mostra un orologio con fatti utili dopo 5 minuti di inattività. Di default è disattivato (lo schermo resta sempre acceso).</string>
|
||||
<string name="setup_screensaver_toggle_label">Attiva salvaschermo</string>
|
||||
<string name="setup_screensaver_toggle_hint">Se disattivo, lo schermo resta sempre acceso.</string>
|
||||
|
||||
<!-- Riepilogo -->
|
||||
<string name="summary_lang">Lingua</string>
|
||||
<string name="summary_scale_skip">Bilancia: non configurata</string>
|
||||
<string name="summary_screensaver_on">Salvaschermo: attivo</string>
|
||||
<string name="summary_screensaver_off">Schermo sempre acceso (salvaschermo disattivato)</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#10B981</color>
|
||||
<color name="bg_dark">#0f172a</color>
|
||||
<color name="bg_card">#1e293b</color>
|
||||
<color name="text_primary">#f1f5f9</color>
|
||||
<color name="text_secondary">#94a3b8</color>
|
||||
<color name="text_muted">#64748b</color>
|
||||
<color name="accent_purple">#7c3aed</color>
|
||||
<color name="accent_green">#059669</color>
|
||||
<color name="border">#334155</color>
|
||||
<color name="success">#34d399</color>
|
||||
<color name="error">#f87171</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,72 @@
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
|
||||
<!-- Setup wizard strings -->
|
||||
<string name="setup_enter_url">Please enter a URL first</string>
|
||||
<string name="setup_testing">Testing connection…</string>
|
||||
<string name="setup_server_found">EverShelf server found and API active!</string>
|
||||
<string name="setup_api_not_found">Server reachable but EverShelf API not found. Check the path.</string>
|
||||
<string name="setup_unreachable">Cannot reach server</string>
|
||||
<string name="setup_discover_btn">🔍 Search local network</string> <string name="setup_perms_granted_next">✅ Permissions granted — Continue →</string> <string name="setup_discovering">Scanning…</string>
|
||||
<string name="setup_discovering_detail">Searching for EverShelf servers on the local network…</string>
|
||||
<string name="setup_discover_not_found">No EverShelf server found automatically. Enter the URL manually.</string>
|
||||
<string name="setup_exit_title">Exit setup?</string>
|
||||
<string name="setup_exit_message">You can complete setup later when you reopen the app.</string>
|
||||
<string name="setup_exit_confirm">Exit</string>
|
||||
<string name="setup_exit_cancel">Continue</string>
|
||||
|
||||
<!-- Wizard Step 3: Smart scale -->
|
||||
<string name="wizard_step3_title">Smart Scale</string>
|
||||
<string name="wizard_step3_description">EverShelf Kiosk includes a built-in Bluetooth gateway — no external app needed. Select your scale below.</string>
|
||||
<string name="wizard_step3_question">Do you have a Bluetooth smart scale?</string>
|
||||
<string name="wizard_step3_yes">✅ Yes, I have a scale</string>
|
||||
<string name="wizard_step3_no">➡️ No, skip this step</string>
|
||||
|
||||
<!-- Gateway status messages -->
|
||||
<string name="wizard_gateway_installed">Scale device saved ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">The integrated BLE gateway will connect automatically on startup.</string>
|
||||
<string name="wizard_gateway_not_installed">No scale selected</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Scan for nearby BLE scales and tap one to select it.</string>
|
||||
<string name="wizard_gateway_checking">Scanning for BLE scales…</string>
|
||||
<string name="wizard_gateway_up_to_date">Scale BLE service ready.</string>
|
||||
<string name="wizard_gateway_update_available">BLE scale found</string>
|
||||
<string name="wizard_gateway_update_detail">Tap the scale in the list to connect.</string>
|
||||
|
||||
<!-- Install / download progress states -->
|
||||
<string name="install_downloading">Scaricamento in corso…</string>
|
||||
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
|
||||
<string name="install_installing">Installazione in corso…</string>
|
||||
<string name="install_confirm_detail">Conferma l\'installazione nel dialog che si è aperto.</string>
|
||||
<string name="install_success">Installato con successo!</string>
|
||||
<string name="install_success_detail">L\'app è stata aggiornata.</string>
|
||||
<string name="install_error_download">Download fallito</string>
|
||||
<string name="install_error_download_detail">Controlla la connessione e riprova.</string>
|
||||
<string name="install_error_install">Installazione fallita</string>
|
||||
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
|
||||
<string name="install_btn_retry">↩ Riprova</string>
|
||||
|
||||
<!-- Buttons -->
|
||||
<string name="btn_back">Back</string>
|
||||
<string name="btn_launch">🚀 Launch EverShelf</string>
|
||||
<string name="btn_launch_no_scale">🚀 Launch without scale</string>
|
||||
<string name="btn_download_gateway">📥 Install Scale Gateway</string>
|
||||
<string name="btn_update_gateway">📥 Update Scale Gateway</string>
|
||||
|
||||
<!-- Server reachability check (wizard step 3) -->
|
||||
<string name="wizard_server_checking">Checking server connection…</string>
|
||||
<string name="wizard_server_ok">Server reachable ✅</string>
|
||||
<string name="wizard_server_ok_detail">Error reporting is active — install failures will be sent to GitHub Issues automatically.</string>
|
||||
<string name="wizard_server_error">Server not reachable ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Install errors won\'t reach GitHub Issues. Check the URL entered in step 2.</string>
|
||||
<!-- Screensaver step -->
|
||||
<string name="setup_screensaver_title">Salvaschermo in-app</string>
|
||||
<string name="setup_screensaver_desc">Shows a clock with useful facts after 5 minutes of inactivity. Off by default (screen stays always on).</string>
|
||||
<string name="setup_screensaver_toggle_label">Abilita salvaschermo orologio</string>
|
||||
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min. Lo schermo resta sempre acceso.</string>
|
||||
|
||||
<!-- Summary -->
|
||||
<string name="summary_lang">Language</string>
|
||||
<string name="summary_scale_skip">Scale: not configured</string>
|
||||
<string name="summary_screensaver_on">Screensaver: enabled</string>
|
||||
<string name="summary_screensaver_off">Screen always on (screensaver disabled)</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,3 @@
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# Build trigger: TTS bridge fix (95389eb)
|
||||
@@ -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 Kiosk"
|
||||
include(":app")
|
||||
@@ -0,0 +1,7 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
*.apk
|
||||
*.aab
|
||||
*.class
|
||||
*.dex
|
||||
@@ -1,4 +1,16 @@
|
||||
# EverShelf Scale Gateway
|
||||
# ~~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.
|
||||
|
||||
@@ -7,10 +19,12 @@
|
||||
## How it works
|
||||
|
||||
```
|
||||
Smart Scale ──(BLE)──► Android Gateway App ──(WebSocket/LAN)──► EverShelf (browser)
|
||||
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. EverShelf connects to it over your home Wi-Fi and receives weight readings in real time.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "it.dadaloop.evershelf.scalegate"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 2
|
||||
versionName = "1.3.0"
|
||||
versionCode = 8
|
||||
versionName = "2.1.1"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
<!-- 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
|
||||
@@ -45,6 +48,17 @@
|
||||
</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>
|
||||
|
||||
@@ -13,6 +13,8 @@ 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.
|
||||
@@ -21,6 +23,8 @@ data class BleDeviceInfo(
|
||||
val device: BluetoothDevice,
|
||||
val name: String,
|
||||
val rssi: Int,
|
||||
val proximity: String,
|
||||
val scaleScore: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -35,6 +39,7 @@ interface BleScaleListener {
|
||||
fun onBatteryReceived(level: Int)
|
||||
fun onError(message: String)
|
||||
fun onScanStopped()
|
||||
fun onDebugEvent(message: String)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +58,7 @@ class BleScaleManager(
|
||||
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>()
|
||||
@@ -61,6 +67,22 @@ class BleScaleManager(
|
||||
|
||||
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 {
|
||||
@@ -76,11 +98,11 @@ class BleScaleManager(
|
||||
|
||||
fun startScan() {
|
||||
val adapter = bluetoothAdapter ?: run {
|
||||
listener.onError("Bluetooth non disponibile su questo dispositivo.")
|
||||
listener.onError("Bluetooth not available on this device.")
|
||||
return
|
||||
}
|
||||
if (!adapter.isEnabled) {
|
||||
listener.onError("Bluetooth disattivato. Attivalo e riprova.")
|
||||
listener.onError("Bluetooth is off. Enable it and try again.")
|
||||
return
|
||||
}
|
||||
if (isScanning) stopScan()
|
||||
@@ -90,17 +112,12 @@ class BleScaleManager(
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
|
||||
// Filter for known weight/body-composition services; also allow unfiltered scan
|
||||
val filters: List<ScanFilter> = listOf(
|
||||
ScanFilter.Builder().setServiceUuid(android.os.ParcelUuid(BleUuids.WEIGHT_SCALE_SERVICE)).build(),
|
||||
ScanFilter.Builder().setServiceUuid(android.os.ParcelUuid(BleUuids.BODY_COMPOSITION_SERVICE)).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(filters, settings, scanCallback)
|
||||
leScanner?.startScan(null, settings, scanCallback)
|
||||
} catch (e: Exception) {
|
||||
// Some devices reject filtered scan; fall back to unfiltered
|
||||
leScanner?.startScan(scanCallback)
|
||||
}
|
||||
|
||||
@@ -123,31 +140,87 @@ class BleScaleManager(
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val device = result.device
|
||||
val name = getDeviceName(device).takeIf { it.isNotBlank() } ?: return
|
||||
val info = BleDeviceInfo(device, name, result.rssi)
|
||||
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("Scansione BLE fallita (codice: $errorCode)") }
|
||||
mainHandler.post { listener.onError("BLE scan failed (code: $errorCode)") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeviceName(device: BluetoothDevice): String {
|
||||
return try {
|
||||
device.name?.takeIf { it.isNotBlank() } ?: device.address
|
||||
device.name?.takeIf { it.isNotBlank() } ?: "Unnamed"
|
||||
} catch (e: SecurityException) {
|
||||
device.address
|
||||
"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) {
|
||||
@@ -156,7 +229,7 @@ class BleScaleManager(
|
||||
device.connectGatt(context, false, gattCallback)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
mainHandler.post { listener.onError("Permesso mancante: ${e.message}") }
|
||||
mainHandler.post { listener.onError("Missing permission: ${e.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,34 +271,44 @@ class BleScaleManager(
|
||||
|
||||
val targetChars = mutableListOf<BluetoothGattCharacteristic>()
|
||||
|
||||
// Priority 1: Weight Scale Service
|
||||
// Priority 1: BLE SIG Weight Scale Service
|
||||
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)
|
||||
?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)
|
||||
?.let { targetChars.add(it) }
|
||||
|
||||
// Priority 2: Body Composition Service
|
||||
gatt.getService(BleUuids.BODY_COMPOSITION_SERVICE)
|
||||
?.getCharacteristic(BleUuids.BODY_COMPOSITION_CHAR)
|
||||
?.let { if (!targetChars.contains(it)) 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) }
|
||||
}
|
||||
|
||||
// Fallback: any notifiable characteristic from unknown services
|
||||
// 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) {
|
||||
// Skip standard generic 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) {
|
||||
targetChars.add(char)
|
||||
if (!targetChars.contains(char)) targetChars.add(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetChars.isEmpty()) {
|
||||
mainHandler.post { listener.onError("Nessuna caratteristica peso trovata su questa bilancia.") }
|
||||
mainHandler.post { listener.onError("No weight characteristic found. Make sure it's a BLE kitchen scale.") }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -234,10 +317,33 @@ class BleScaleManager(
|
||||
?.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 ?: "Bilancia" } catch (e: SecurityException) { "Bilancia" }
|
||||
val deviceName = try { gatt.device?.name ?: "Scale" } catch (e: SecurityException) { "Scale" }
|
||||
connectedDeviceName = deviceName
|
||||
mainHandler.post { listener.onConnected(deviceName) }
|
||||
|
||||
@@ -327,10 +433,23 @@ class BleScaleManager(
|
||||
return
|
||||
}
|
||||
|
||||
// Weight / body composition
|
||||
val reading = ScaleProtocol.parse(char, data) ?: return
|
||||
if (reading.weightKg > 0f) {
|
||||
// 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 }
|
||||
}
|
||||
@@ -25,9 +25,9 @@ interface ServerEventListener {
|
||||
*
|
||||
* Message protocol (JSON):
|
||||
*
|
||||
* Server → Client:
|
||||
* {"type":"status","state":"connected"|"disconnected","device":"Mi Scale 2","battery":80}
|
||||
* {"type":"weight","value":72.5,"unit":"kg","stable":true,"timestamp":1712345678000}
|
||||
* 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:
|
||||
@@ -94,6 +94,8 @@ class GatewayWebSocketServer(
|
||||
|
||||
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 ────────────────────────────────────────────────────────
|
||||
@@ -110,8 +112,8 @@ class GatewayWebSocketServer(
|
||||
* Broadcast a weight reading to all clients.
|
||||
* If [stable] is true, also fulfil pending on-demand weight requests.
|
||||
*/
|
||||
fun publishWeight(weightKg: Float, stable: Boolean, battery: Int? = null) {
|
||||
val json = buildWeightJson(weightKg, stable)
|
||||
fun publishWeight(value: Float, unit: String, stable: Boolean, battery: Int? = null) {
|
||||
val json = buildWeightJson(value, unit, stable)
|
||||
lastWeightJson = json
|
||||
broadcast(json)
|
||||
|
||||
@@ -135,13 +137,13 @@ class GatewayWebSocketServer(
|
||||
return obj.toString()
|
||||
}
|
||||
|
||||
private fun buildWeightJson(weightKg: Float, stable: Boolean): String {
|
||||
// Round to 2 decimal places
|
||||
val rounded = (weightKg * 100).toLong() / 100.0
|
||||
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", "kg")
|
||||
obj.put("unit", unit)
|
||||
obj.put("stable", stable)
|
||||
obj.put("timestamp", System.currentTimeMillis())
|
||||
return obj.toString()
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
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
|
||||
|
||||
@@ -33,6 +48,20 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -42,8 +71,8 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
if (granted.values.all { it }) {
|
||||
startGatewayServer()
|
||||
} else {
|
||||
showDialog("Permessi mancanti",
|
||||
"L'app necessita dei permessi Bluetooth e Posizione per funzionare.")
|
||||
showDialog("Missing permissions",
|
||||
"The app requires Bluetooth and Location permissions to function.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +80,46 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == RESULT_OK) checkPermissionsAndStart()
|
||||
else showDialog("Bluetooth richiesto", "Attiva il Bluetooth per usare il gateway.")
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
@@ -63,6 +131,10 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -76,9 +148,50 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
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() {
|
||||
@@ -122,9 +235,13 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
}
|
||||
devices.clear()
|
||||
deviceAdapter.notifyDataSetChanged()
|
||||
debugLines.clear()
|
||||
binding.tvDebugLog.text = ""
|
||||
binding.tvScanHint.visibility = View.VISIBLE
|
||||
binding.tvScanHint.text = "Ricerca bilance BLE in corso…"
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -136,9 +253,16 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
wsServer = GatewayWebSocketServer(WS_PORT, this)
|
||||
wsServer!!.start()
|
||||
updateGatewayUrl()
|
||||
binding.tvGatewayStatus.text = "✅ Gateway attivo sulla porta $WS_PORT"
|
||||
binding.tvGatewayStatus.text = "\u2705 Gateway active on port $WS_PORT"
|
||||
} catch (e: Exception) {
|
||||
binding.tvGatewayStatus.text = "❌ Impossibile avviare il gateway: ${e.message}"
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,35 +270,38 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
val ip = getLocalIpAddress() ?: "—"
|
||||
val url = "ws://$ip:$WS_PORT"
|
||||
binding.tvGatewayUrl.text = url
|
||||
binding.tvGatewayUrlHint.text = "Incolla questo URL in EverShelf → Impostazioni → Bilancia Smart"
|
||||
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 = "✅ Copiato!"
|
||||
binding.btnCopyUrl.postDelayed({ binding.btnCopyUrl.text = "📋 Copia URL" }, 2000)
|
||||
binding.btnCopyUrl.text = "\u2705 Copied!"
|
||||
binding.btnCopyUrl.postDelayed({ binding.btnCopyUrl.text = "\uD83D\uDCCB Copy URL" }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BleScaleListener ─────────────────────────────────────────────────────
|
||||
|
||||
override fun onDeviceFound(info: BleDeviceInfo) {
|
||||
// Avoid duplicates
|
||||
if (devices.none { it.device.address == info.device.address }) {
|
||||
devices.add(info)
|
||||
deviceAdapter.notifyItemInserted(devices.size - 1)
|
||||
// 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 = "⏳ Connessione a $name…"
|
||||
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) {
|
||||
binding.tvScaleStatus.text = "✅ Connessa: $deviceName"
|
||||
binding.tvWeight.text = "In attesa di un peso…"
|
||||
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
|
||||
@@ -186,21 +313,33 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
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 kg = "%.2f".format(reading.weightKg)
|
||||
val extras = buildString {
|
||||
reading.fatPct?.let { append(" Grasso: ${"%.1f".format(it)}%") }
|
||||
reading.bmi?.let { append(" BMI: ${"%.1f".format(it)}") }
|
||||
}
|
||||
binding.tvWeight.text = "$kg kg$extras"
|
||||
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 = "✓ Lettura stabile"
|
||||
binding.tvWeightHint.text = "\u2713 Stable reading"
|
||||
} else {
|
||||
binding.tvWeightHint.text = "…"
|
||||
binding.tvWeightHint.text = "\u23f3 Measuring\u2026"
|
||||
}
|
||||
wsServer?.publishWeight(reading.weightKg, reading.stable, batteryLevel)
|
||||
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, batteryLevel)
|
||||
}
|
||||
|
||||
override fun onBatteryReceived(level: Int) {
|
||||
@@ -208,20 +347,54 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
binding.tvBattery.text = "🔋 $level%"
|
||||
binding.tvBattery.visibility = View.VISIBLE
|
||||
wsServer?.publishStatus("connected", binding.tvScaleStatus.text.toString()
|
||||
.removePrefix("✅ Connessa: "), level)
|
||||
.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 (devices.isEmpty()) {
|
||||
binding.tvScanHint.text = "Nessuna bilancia trovata. Assicurati che sia accesa e premi di nuovo Cerca."
|
||||
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 = "Tocca una bilancia per connettersi."
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +402,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
|
||||
override fun onClientConnected(address: String) {
|
||||
runOnUiThread {
|
||||
binding.tvClientCount.text = "🌐 Client connesso: $address"
|
||||
binding.tvClientCount.text = "\uD83C\uDF10 Client connected: $address"
|
||||
binding.tvClientCount.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
@@ -245,7 +418,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
// ─── UI helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private fun updateUiDisconnected() {
|
||||
binding.tvScaleStatus.text = "⚡ Pronto — cerca una bilancia"
|
||||
binding.tvScaleStatus.text = "\u26a1 Ready \u2014 scan for a scale"
|
||||
binding.tvWeight.text = "— — —"
|
||||
binding.tvWeightHint.text = ""
|
||||
binding.tvBattery.visibility = View.GONE
|
||||
@@ -273,6 +446,202 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
.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(
|
||||
@@ -296,7 +665,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
val info = items[position]
|
||||
holder.tvName.text = info.name
|
||||
holder.tvAddr.text = info.device.address
|
||||
holder.tvRssi.text = "${info.rssi} dBm"
|
||||
holder.tvRssi.text = info.proximity
|
||||
holder.itemView.setOnClickListener { onClick(info) }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,171 +3,206 @@ package it.dadaloop.evershelf.scalegate
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import java.util.UUID
|
||||
|
||||
// --- Data model ---
|
||||
|
||||
/**
|
||||
* Data model for a single weight reading from a BLE scale.
|
||||
* 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 weightKg: Float, // weight in kilograms
|
||||
val stable: Boolean, // true when the reading is stable/final
|
||||
val battery: Int? = null, // battery percentage (0-100), if reported
|
||||
val fatPct: Float? = null, // body fat %, if available
|
||||
val bmi: Float? = null, // BMI, if available
|
||||
val value: Float,
|
||||
val unit: String,
|
||||
val stable: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Descriptor UUID for enabling BLE notifications (standard 0x2902).
|
||||
*/
|
||||
// --- UUIDs ---
|
||||
|
||||
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
/**
|
||||
* Bluetooth SIG standard service and characteristic UUIDs.
|
||||
*/
|
||||
object BleUuids {
|
||||
// Weight Scale Service
|
||||
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
|
||||
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
|
||||
// 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")
|
||||
|
||||
// Body Composition Service (also used by many smart scales)
|
||||
val BODY_COMPOSITION_SERVICE = UUID.fromString("0000181b-0000-1000-8000-00805f9b34fb")
|
||||
val BODY_COMPOSITION_CHAR = UUID.fromString("00002a9c-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")
|
||||
|
||||
// Battery Service
|
||||
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")
|
||||
|
||||
// Xiaomi Mi Scale 2 / Mi Body Composition Scale 2
|
||||
val XIAOMI_SCALE_SERVICE = UUID.fromString("0000181b-0000-1000-8000-00805f9b34fb")
|
||||
val XIAOMI_SCALE_CHAR = UUID.fromString("00002a9c-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")
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses BLE characteristic data for various scale protocols.
|
||||
* Returns a WeightReading or null if the data does not match a known format.
|
||||
*/
|
||||
// --- Food scale protocol parser ---
|
||||
|
||||
object ScaleProtocol {
|
||||
|
||||
/**
|
||||
* Attempt to parse weight data from a GATT characteristic change.
|
||||
* Tries known protocols in order of specificity.
|
||||
*/
|
||||
fun parse(char: BluetoothGattCharacteristic, data: ByteArray): WeightReading? {
|
||||
return when (char.uuid) {
|
||||
BleUuids.WEIGHT_MEASUREMENT_CHAR -> parseWeightMeasurement(data)
|
||||
BleUuids.BODY_COMPOSITION_CHAR -> parseBodyComposition(data)
|
||||
else -> parseGeneric(data)
|
||||
// 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bluetooth SIG Weight Measurement Characteristic (0x2A9D)
|
||||
*
|
||||
* Byte 0 : Flags
|
||||
* Bit 0 = 0 → SI (kg/m), 1 → Imperial (lb/in)
|
||||
* Bit 1 = Time Stamp present
|
||||
* Bit 2 = User ID present
|
||||
* Bit 3 = BMI & Height present
|
||||
* Bytes 1-2: Weight (uint16)
|
||||
* SI: 0.005 kg per unit
|
||||
* Imperial: 0.01 lb per unit
|
||||
*/
|
||||
fun parseWeightMeasurement(data: ByteArray): WeightReading? {
|
||||
// -------------------------------------------------------------------------
|
||||
// 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 flags = data[0].toInt() and 0xFF
|
||||
val isImperial = (flags and 0x01) != 0
|
||||
val rawWeight = (data[1].toInt() and 0xFF) or ((data[2].toInt() and 0xFF) shl 8)
|
||||
val raw = u16le(data, 1)
|
||||
|
||||
val weightKg = if (isImperial) {
|
||||
rawWeight * 0.01f / 2.20462f // lb → kg
|
||||
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 {
|
||||
rawWeight * 0.005f // SI resolution
|
||||
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)
|
||||
}
|
||||
|
||||
// Bit 3: BMI & Height present → offset 5 if no timestamp/user
|
||||
var bmi: Float? = null
|
||||
var offset = 3
|
||||
if ((flags and 0x02) != 0) offset += 7 // timestamp: 7 bytes
|
||||
if ((flags and 0x04) != 0) offset += 1 // user ID: 1 byte
|
||||
if ((flags and 0x08) != 0 && data.size >= offset + 4) {
|
||||
val rawBmi = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
|
||||
bmi = rawBmi * 0.1f
|
||||
}
|
||||
|
||||
return WeightReading(weightKg = weightKg, stable = true, bmi = bmi)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bluetooth SIG Body Composition Measurement Characteristic (0x2A9C)
|
||||
*
|
||||
* Bytes 0-1 : Flags (16-bit)
|
||||
* Bit 0 = 0 → SI, 1 → Imperial
|
||||
* Bit 1 = Time Stamp present
|
||||
* Bit 7 = Weight present
|
||||
* Bit 8 = Height present
|
||||
* Bit 9 = Multiple Users
|
||||
* Bit 10 = Basal Metabolism present
|
||||
* Bit 11 = Muscle Percentage present
|
||||
* Bit 13 = Body Fat Percentage present ← always present (mandatory)
|
||||
* Bytes 2-3 : Body Fat % (uint16, resolution 0.1%)
|
||||
* … then optional fields
|
||||
* When Bit 7 (Weight) is set, weight (uint16) follows at an offset after other optionals.
|
||||
*/
|
||||
fun parseBodyComposition(data: ByteArray): WeightReading? {
|
||||
if (data.size < 4) return null
|
||||
val flags = (data[0].toInt() and 0xFF) or ((data[1].toInt() and 0xFF) shl 8)
|
||||
val isImperial = (flags and 0x0001) != 0
|
||||
|
||||
// Body fat % (mandatory, bytes 2-3)
|
||||
val rawFat = (data[2].toInt() and 0xFF) or ((data[3].toInt() and 0xFF) shl 8)
|
||||
val fatPct = rawFat * 0.1f
|
||||
|
||||
// Walk through optional fields to reach weight
|
||||
var offset = 4
|
||||
if ((flags and 0x0002) != 0) offset += 7 // timestamp
|
||||
if ((flags and 0x0200) != 0) offset += 1 // multiple users → User ID byte
|
||||
|
||||
// Weight (Bit 7)
|
||||
var weightKg: Float? = null
|
||||
if ((flags and 0x0080) != 0 && data.size >= offset + 2) {
|
||||
val rawW = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
|
||||
weightKg = if (isImperial) rawW * 0.01f / 2.20462f else rawW * 0.005f
|
||||
offset += 2
|
||||
// -------------------------------------------------------------------------
|
||||
// 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
|
||||
}
|
||||
|
||||
if (weightKg == null || weightKg <= 0f) 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"
|
||||
}
|
||||
|
||||
return WeightReading(
|
||||
weightKg = weightKg,
|
||||
stable = true,
|
||||
fatPct = if (fatPct > 0f) fatPct else null,
|
||||
)
|
||||
// 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.
|
||||
* Many cheap BLE scales send 2 bytes or a small packet with weight as a little-endian uint16
|
||||
* in units of 0.1 kg, 0.01 kg, or 10 g. We try each interpretation and pick a plausible result.
|
||||
*/
|
||||
fun parseGeneric(data: ByteArray): WeightReading? {
|
||||
if (data.size < 2) return null
|
||||
// -------------------------------------------------------------------------
|
||||
// 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)
|
||||
|
||||
// Try common byte positions
|
||||
val candidates = listOf(
|
||||
// (startByte, resolution in kg, stabilityBit, stabilityByte, stabilityValue)
|
||||
Triple(data.size - 2, 0.01f, false), // last 2 bytes, 0.01 kg resolution
|
||||
Triple(data.size - 2, 0.005f, false), // last 2 bytes, 0.005 kg resolution
|
||||
Triple(1, 0.01f, false), // bytes 1-2, 0.01 kg
|
||||
Triple(0, 0.1f, false), // bytes 0-1, 0.1 kg
|
||||
// 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 ((start, resolution, _) in candidates) {
|
||||
if (start < 0 || start + 1 >= data.size) continue
|
||||
val raw = (data[start].toInt() and 0xFF) or ((data[start + 1].toInt() and 0xFF) shl 8)
|
||||
val weight = raw * resolution
|
||||
// Sanity check: a realistic weight is between 1 kg and 300 kg
|
||||
if (weight in 1f..300f) {
|
||||
return WeightReading(weightKg = weight, stable = true)
|
||||
for (c in candidates) {
|
||||
if (c.pos + 1 >= data.size) continue
|
||||
val raw = if (c.be) u16be(data, c.pos) else u16le(data, c.pos)
|
||||
if (raw == 0) continue
|
||||
val g = raw / c.div
|
||||
if (g in MIN_GRAMS..MAX_GRAMS) {
|
||||
debug?.invoke("generic [${c.label}]: raw=$raw -> ${g}g (unstable)")
|
||||
return WeightReading(g, "g", stable = false)
|
||||
}
|
||||
}
|
||||
debug?.invoke("generic: no valid candidate in " + data.size + " bytes")
|
||||
return null
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
private fun u16le(b: ByteArray, off: Int): Int =
|
||||
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
private fun u16be(b: ByteArray, off: Int): Int =
|
||||
((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<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"
|
||||
@@ -21,13 +74,31 @@
|
||||
android:textColor="#1E293B"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Collega la tua bilancia smart a EverShelf via Bluetooth"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#64748B"
|
||||
android:paddingBottom="16dp" />
|
||||
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
|
||||
@@ -47,7 +118,7 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🌐 URL Gateway (incolla in EverShelf)"
|
||||
android:text="🌐 Gateway URL (paste into EverShelf)"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#64748B"
|
||||
android:paddingBottom="4dp" />
|
||||
@@ -67,7 +138,7 @@
|
||||
android:id="@+id/tv_gateway_url_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Impostazioni → Bilancia Smart"
|
||||
android:text="Settings → Smart Scale"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#94A3B8"
|
||||
android:paddingBottom="8dp" />
|
||||
@@ -76,7 +147,7 @@
|
||||
android:id="@+id/btn_copy_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📋 Copia URL"
|
||||
android:text="📋 Copy URL"
|
||||
android:backgroundTint="#1D4ED8"
|
||||
android:textColor="#FFFFFF"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
@@ -89,7 +160,7 @@
|
||||
android:id="@+id/tv_gateway_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⏳ Avvio gateway…"
|
||||
android:text="⏳ Starting gateway…"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#64748B"
|
||||
android:paddingBottom="4dp" />
|
||||
@@ -124,7 +195,7 @@
|
||||
android:id="@+id/tv_scale_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⚡ Pronto — cerca una bilancia"
|
||||
android:text="⚡ Ready — scan for a scale"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#FFFFFF"
|
||||
@@ -165,7 +236,7 @@
|
||||
android:id="@+id/btn_disconnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔌 Disconnetti bilancia"
|
||||
android:text="🔌 Disconnect scale"
|
||||
android:backgroundTint="#EF4444"
|
||||
android:textColor="#FFFFFF"
|
||||
android:visibility="gone"
|
||||
@@ -180,17 +251,78 @@
|
||||
android:id="@+id/btn_scan"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔍 Cerca Bilance Bluetooth"
|
||||
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="Premi per cercare bilance BLE nelle vicinanze.\nAssicurati che la bilancia sia accesa."
|
||||
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"
|
||||
@@ -204,4 +336,5 @@
|
||||
android:nestedScrollingEnabled="false" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
|
||||