Compare commits
483 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cb4ae13f1 | |||
| 189b640309 | |||
| da4bd635db | |||
| ab6aca2f01 | |||
| 850c5047b8 | |||
| 3e44f5bb24 | |||
| 02964ecf23 | |||
| 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 | |||
| 7ff7f56e0b | |||
| 2740be3bdf | |||
| d1c46a0bcb | |||
| df9de2d257 | |||
| fb134128fe | |||
| 329eed5082 | |||
| 0a11214d3d | |||
| 390221ed4c | |||
| 38d8fa7afe | |||
| 18af2e9ef4 | |||
| 3fcded1d9b | |||
| b7dd197944 | |||
| 9523b68fea | |||
| 0893742f05 | |||
| 7fa8395e9e | |||
| f2b518dd4b | |||
| 2f21be8829 | |||
| 7e9ae24f88 | |||
| 0c6a07ee95 | |||
| 66b75b1537 | |||
| 874a242149 | |||
| a1873d3f81 | |||
| c52a91e779 | |||
| 20f734d54a | |||
| 2ea0c68f2e | |||
| da962581c0 | |||
| 499552e4df | |||
| 4b5979333e | |||
| c9b3eb01cc | |||
| 82f147d8d5 | |||
| 38866e3daf | |||
| ef654b9dfc | |||
| 0b863b1cad | |||
| d75b889d8e | |||
| d66bdc146c | |||
| faaae1eede |
@@ -1,5 +1,5 @@
|
|||||||
# Docker runtime files
|
# Docker runtime files
|
||||||
data/dispensa.db
|
data/evershelf.db
|
||||||
data/*.db-wal
|
data/*.db-wal
|
||||||
data/*.db-shm
|
data/*.db-shm
|
||||||
data/backups/
|
data/backups/
|
||||||
@@ -7,7 +7,6 @@ data/cron.log
|
|||||||
data/smart_shopping_cache.json
|
data/smart_shopping_cache.json
|
||||||
data/bring_token.json
|
data/bring_token.json
|
||||||
data/bring_catalog.json
|
data/bring_catalog.json
|
||||||
data/dupliclick_token.json
|
|
||||||
data/client_debug.log
|
data/client_debug.log
|
||||||
data/*.crt
|
data/*.crt
|
||||||
data/*.pem
|
data/*.pem
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Dispensa Manager - Configuration
|
# EverShelf - Configuration
|
||||||
# Copy this file to .env and fill in your values
|
# Copy this file to .env and fill in your values
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
|
|
||||||
@@ -20,3 +20,6 @@ TTS_AUTH_TYPE=bearer
|
|||||||
TTS_CONTENT_TYPE=application/json
|
TTS_CONTENT_TYPE=application/json
|
||||||
TTS_PAYLOAD_KEY=message
|
TTS_PAYLOAD_KEY=message
|
||||||
TTS_ENABLED=false
|
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 @@
|
|||||||
|
ko_fi: dadaloop82
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Report a bug or unexpected behavior in EverShelf
|
||||||
|
title: "[BUG] "
|
||||||
|
labels: ["bug"]
|
||||||
|
assignees: ["dadaloop82"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to report a bug! Please fill in the details below.
|
||||||
|
Before submitting, check the [FAQ](https://github.com/dadaloop82/EverShelf/wiki/FAQ) and [existing issues](https://github.com/dadaloop82/EverShelf/issues?q=is%3Aissue+label%3Abug).
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: EverShelf Version
|
||||||
|
description: Found in Settings → About, or in the footer of the web app.
|
||||||
|
placeholder: "e.g. 1.7.13"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: component
|
||||||
|
attributes:
|
||||||
|
label: Component
|
||||||
|
description: Which part of EverShelf is affected?
|
||||||
|
options:
|
||||||
|
- Web app (browser / PWA)
|
||||||
|
- Android Kiosk app
|
||||||
|
- API / PHP backend
|
||||||
|
- Docker setup
|
||||||
|
- Bring! integration
|
||||||
|
- AI features (Gemini)
|
||||||
|
- Smart Scale
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Bug Description
|
||||||
|
description: A clear and concise description of the bug.
|
||||||
|
placeholder: "What went wrong?"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: How can we reproduce this?
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Tap '...'
|
||||||
|
3. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: What should have happened?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior
|
||||||
|
description: What actually happened? Include error messages, screenshots, or console output.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: browser
|
||||||
|
attributes:
|
||||||
|
label: Browser / OS
|
||||||
|
placeholder: "e.g. Chrome 124 on Android 13, Safari on iOS 17, Firefox on Ubuntu 22.04"
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: php
|
||||||
|
attributes:
|
||||||
|
label: PHP Version (if relevant)
|
||||||
|
placeholder: "e.g. 8.2.12 — run: php -v"
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: install
|
||||||
|
attributes:
|
||||||
|
label: Installation Method
|
||||||
|
options:
|
||||||
|
- Docker (docker compose)
|
||||||
|
- Manual (Apache/Nginx)
|
||||||
|
- Other
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Relevant Logs
|
||||||
|
description: PHP error log, browser console output, or `data/error_reports.log` snippet.
|
||||||
|
render: text
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
options:
|
||||||
|
- label: I searched existing issues and this is not a duplicate
|
||||||
|
required: true
|
||||||
|
- label: I checked the FAQ
|
||||||
|
required: true
|
||||||
|
- label: I am on the latest version (or this bug exists on the latest version)
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 📖 Wiki & FAQ
|
||||||
|
url: https://github.com/dadaloop82/EverShelf/wiki/FAQ
|
||||||
|
about: Check the FAQ — your question may already be answered there.
|
||||||
|
- name: 💬 Discussions — Q&A
|
||||||
|
url: https://github.com/dadaloop82/EverShelf/discussions
|
||||||
|
about: General questions, show-and-tell, ideas — use Discussions, not Issues.
|
||||||
|
- name: 🔒 Security Vulnerability
|
||||||
|
url: mailto:evershelfproject@gmail.com
|
||||||
|
about: Please report security vulnerabilities privately via email, not as a public issue.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest a new feature or improvement
|
||||||
|
title: "[FEATURE] "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
assignees: ["dadaloop82"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for the idea! Check the [Roadmap](https://github.com/dadaloop82/EverShelf/blob/main/README.md#-roadmap) and [Discussions](https://github.com/dadaloop82/EverShelf/discussions) first — it may already be planned or discussed.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: Category
|
||||||
|
options:
|
||||||
|
- Inventory management
|
||||||
|
- Shopping list
|
||||||
|
- AI / Gemini features
|
||||||
|
- Cooking mode
|
||||||
|
- Dashboard / stats
|
||||||
|
- Kiosk app
|
||||||
|
- Smart Scale
|
||||||
|
- Integrations (Bring!, HA, etc.)
|
||||||
|
- Performance / developer experience
|
||||||
|
- Translations / i18n
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem / Motivation
|
||||||
|
description: What pain point does this address? Why do you need this?
|
||||||
|
placeholder: "I'm always frustrated when..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution
|
||||||
|
description: Describe what you'd like to see added or changed.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered
|
||||||
|
description: Any workarounds you've tried, or other solutions you considered?
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Screenshots, mockups, links to similar features in other apps, etc.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
options:
|
||||||
|
- label: I checked the Roadmap and this is not already planned
|
||||||
|
required: true
|
||||||
|
- label: I searched existing issues and discussions — this is not a duplicate
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- What does this PR do? Link the related issue: "Closes #123" or "Relates to #123" -->
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type of Change
|
||||||
|
|
||||||
|
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||||
|
- [ ] New feature (non-breaking change that adds functionality)
|
||||||
|
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
||||||
|
- [ ] Refactor / cleanup (no functional change)
|
||||||
|
- [ ] Documentation update
|
||||||
|
- [ ] Translation update
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
<!-- How was this tested? -->
|
||||||
|
|
||||||
|
- [ ] Tested locally (PHP built-in server or Docker)
|
||||||
|
- [ ] Tested on mobile browser
|
||||||
|
- [ ] Tested with Docker Compose: `docker compose up --build`
|
||||||
|
- [ ] PHP syntax: `php -l api/index.php && php -l api/database.php`
|
||||||
|
- [ ] JS syntax: `node --check assets/js/app.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Translation
|
||||||
|
|
||||||
|
- [ ] New user-visible strings added → translation keys added to **all three** files: `translations/it.json`, `en.json`, `de.json`
|
||||||
|
- [ ] No user-visible strings changed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CHANGELOG
|
||||||
|
|
||||||
|
- [ ] Entry added to `CHANGELOG.md` under `## [Unreleased]` or the correct version
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots / Video
|
||||||
|
|
||||||
|
<!-- If this is a UI change, add before/after screenshots. Delete this section if not applicable. -->
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
commit-message:
|
||||||
|
prefix: "ci"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
@@ -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@v6
|
||||||
|
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
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
name: Build & Release Scale Gateway APK (DEPRECATED)
|
||||||
|
# ⚠️ This workflow is disabled. The Scale Gateway is deprecated since Kiosk v1.6.0.
|
||||||
|
# BLE scale support is now built into the EverShelf Kiosk app.
|
||||||
|
# Kept for reference — re-enable manually via workflow_dispatch if needed for legacy setups.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
confirm:
|
||||||
|
description: "Type 'yes' to confirm you want to build the deprecated gateway APK"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build 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@v6
|
||||||
|
with:
|
||||||
|
gradle-version: '8.4'
|
||||||
|
|
||||||
|
- name: Build debug APK
|
||||||
|
run: gradle assembleDebug --no-daemon
|
||||||
|
working-directory: evershelf-scale-gateway
|
||||||
|
|
||||||
|
- name: Rename APK
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
cp evershelf-scale-gateway/app/build/outputs/apk/debug/app-debug.apk artifacts/evershelf-scale-gateway.apk
|
||||||
|
|
||||||
|
- name: Get version name
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep 'versionName' evershelf-scale-gateway/app/build.gradle.kts | grep -oP '"\K[^"]+')
|
||||||
|
echo "name=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Delete existing latest release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: gh release delete latest --yes || true
|
||||||
|
|
||||||
|
- name: Create GitHub Release and upload APK
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh release create latest \
|
||||||
|
--title "EverShelf Scale Gateway v${{ steps.version.outputs.name }}" \
|
||||||
|
--notes "Download the APK and install it on your Android device (7.0+). Allow installation from unknown sources in settings." \
|
||||||
|
--latest \
|
||||||
|
artifacts/evershelf-scale-gateway.apk
|
||||||
@@ -40,14 +40,14 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: docker build -t dispensa-test .
|
run: docker build -t evershelf-test .
|
||||||
|
|
||||||
- name: Test container starts
|
- name: Test container starts
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name test-dispensa -p 8080:80 dispensa-test
|
docker run -d --name test-evershelf -p 8080:80 evershelf-test
|
||||||
sleep 5
|
sleep 5
|
||||||
curl -f http://localhost:8080/ || exit 1
|
curl -f http://localhost:8080/ || exit 1
|
||||||
docker stop test-dispensa
|
docker stop test-evershelf
|
||||||
|
|
||||||
validate-translations:
|
validate-translations:
|
||||||
name: Validate Translation Files
|
name: Validate Translation Files
|
||||||
@@ -86,3 +86,36 @@ jobs:
|
|||||||
else:
|
else:
|
||||||
print(f'{f}: complete ✓')
|
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
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: Security Scan (Trivy)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile'
|
||||||
|
- 'docker-compose.yml'
|
||||||
|
- 'api/**'
|
||||||
|
schedule:
|
||||||
|
# Run weekly on Monday at 07:00 UTC
|
||||||
|
- cron: '0 7 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trivy-docker:
|
||||||
|
name: Trivy — Docker image scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: docker build -t evershelf:scan .
|
||||||
|
|
||||||
|
- name: Run Trivy vulnerability scanner
|
||||||
|
uses: aquasecurity/trivy-action@master
|
||||||
|
with:
|
||||||
|
image-ref: 'evershelf:scan'
|
||||||
|
format: 'sarif'
|
||||||
|
output: 'trivy-results.sarif'
|
||||||
|
severity: 'CRITICAL,HIGH'
|
||||||
|
exit-code: '0' # don't fail the build, just report
|
||||||
|
|
||||||
|
- name: Upload Trivy SARIF to GitHub Security tab
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
with:
|
||||||
|
sarif_file: 'trivy-results.sarif'
|
||||||
|
category: 'trivy-docker'
|
||||||
|
|
||||||
|
trivy-fs:
|
||||||
|
name: Trivy — Filesystem scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run Trivy filesystem scanner
|
||||||
|
uses: aquasecurity/trivy-action@master
|
||||||
|
with:
|
||||||
|
scan-type: 'fs'
|
||||||
|
scan-ref: '.'
|
||||||
|
format: 'sarif'
|
||||||
|
output: 'trivy-fs-results.sarif'
|
||||||
|
severity: 'CRITICAL,HIGH'
|
||||||
|
exit-code: '0'
|
||||||
|
|
||||||
|
- name: Upload Trivy FS SARIF
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
with:
|
||||||
|
sarif_file: 'trivy-fs-results.sarif'
|
||||||
|
category: 'trivy-fs'
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
.env
|
.env
|
||||||
|
|
||||||
# Data directory (user-specific runtime data)
|
# Data directory (user-specific runtime data)
|
||||||
|
data/evershelf.db
|
||||||
data/dispensa.db
|
data/dispensa.db
|
||||||
data/*.db-wal
|
data/*.db-wal
|
||||||
data/*.db-shm
|
data/*.db-shm
|
||||||
@@ -10,7 +11,11 @@ data/cron.log
|
|||||||
data/smart_shopping_cache.json
|
data/smart_shopping_cache.json
|
||||||
data/bring_token.json
|
data/bring_token.json
|
||||||
data/bring_catalog.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/client_debug.log
|
||||||
data/rate_limits/
|
data/rate_limits/
|
||||||
|
|
||||||
@@ -30,3 +35,17 @@ Thumbs.db
|
|||||||
*~
|
*~
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# 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
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteRule ^api/(.*)$ api/index.php?action=$1&%{QUERY_STRING} [L,QSA]
|
RewriteRule ^api/(.*)$ api/index.php?action=$1&%{QUERY_STRING} [L,QSA]
|
||||||
AddType application/x-x509-ca-cert .crt
|
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>
|
||||||
|
|||||||
@@ -1,10 +1,278 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to Dispensa Manager will be documented in this file.
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.7.13] - 2026-05-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Fresh-install crash: `no such column: undone`** — The `transactions` table was created in `initializeDB()` without the `undone` column, but the composite index `idx_transactions_pid_type_undone` immediately referenced it, crashing every new installation at first DB access. Added `undone INTEGER DEFAULT 0` to the transactions schema in `initializeDB()`.
|
||||||
|
- **Race condition: `duplicate column name: package_unit`** — Concurrent API requests on a new installation could all pass the `PRAGMA table_info` guard simultaneously and each try to `ALTER TABLE products ADD COLUMN package_unit`, with all but the first failing with a PDOException. Wrapped all `ALTER TABLE … ADD COLUMN` calls in try/catch to silently ignore duplicate-column errors.
|
||||||
|
|
||||||
|
## [1.7.12] - 2026-05-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **"Use first" banner showed a calculated expiry date** — `_renderUseExpiryHint` was displaying a *calculated* shelf-life date (from opening date) instead of the actual one. When `opened_at` is set, the banner now shows "That one [in the fridge], opened X days ago — use it first!" using the new `use.expiry_warning_opened` translation key.
|
||||||
|
- **"Use All / Done" in recipes deleted the inventory row** — `submitRecipeUse(true)` was sending `use_all: true` to the API, which executed a direct `DELETE` on the inventory row without any confirmation. The function now calculates the exact quantity from the available items (`_recipeUseContext.items`) and sends a regular `inventory_use` with an explicit quantity.
|
||||||
|
- **Recipes: `qty_number` returned in grams for piece-counted (`pz`) items** — The AI prompt and PHP post-processing now instruct Gemini to express `qty_number` as whole pieces for ingredients with unit `pz` (sliced bread, crackers, etc.). The ingredient list in the prompt includes `[use whole PIECES]` for each `pz` product. The PHP fallback for `pz` items without `default_quantity` no longer divides by 100, but uses the AI-returned `qty_number` if it is a plausible count, otherwise defaults to 1.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Translation key `use.expiry_warning_opened`** — New key in `it.json`, `en.json`, `de.json` with `{loc}` (location) and `{when}` (days since opening) placeholders.
|
||||||
|
|
||||||
|
## [1.7.11] - 2026-05-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Scan page redesign** — The scanner page has been completely redesigned for tablet and mobile:
|
||||||
|
- **2× fixed zoom** — hardware zoom if available, otherwise automatic CSS `scale(2)`.
|
||||||
|
- **Torch** — in-viewport button with toast feedback and visual state indicator.
|
||||||
|
- **Camera flip** — front/back switch with persistence in settings.
|
||||||
|
- **3 input tabs** — Barcode / Name / AI for quick access to each scanning mode.
|
||||||
|
- **Recent products** — chips for the last 6 scanned products (localStorage), with category icon.
|
||||||
|
- **Live code overlay** — partially detected barcode shown as overlay in the viewport during partial scan.
|
||||||
|
- **Confirm overlay** — checkmark + product name displayed for 900 ms on successful recognition.
|
||||||
|
- **Guide corners** — visual alignment frame for barcode centering.
|
||||||
|
- **AI Number OCR** — after 4 s without a scan, a "Read numbers with AI" button appears; Gemini analyses the video frame and returns barcode digits even when the optical scanner fails.
|
||||||
|
- **PHP `gemini_number_ocr` endpoint** — New POST endpoint; accepts a base64 JPEG image, asks Gemini to locate the EAN-13 / EAN-8 code printed on the product, and returns the digits or `not_found`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **False consumption anomaly positives (e.g. "Mozzarella 3 pcs")** — Removed the `untracked` direction (consumption higher than recorded purchases), which was generating banners for every product with untracked purchase history. Only `phantom` and `missing` anomalies are now reported.
|
||||||
|
- **"~0 g/week" consumption prediction** — The model now requires a minimum of 5 transactions (was 3) and a time span of at least 7 days; predictions where consumption is < 15% of the baseline are skipped, eliminating false positives for products with few closely-spaced transactions.
|
||||||
|
- **Suggestion dropdown on the Name field (scan page)** — Removed `list="common-products"` from the input field; the datalist is no longer triggered on tablets.
|
||||||
|
|
||||||
|
## [1.7.10] - 2026-05-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **"Set expiry" banner did nothing** — `editBannerNoExpiry()` was calling `openEditInventoryModal()` which does not exist. Fixed to call `editInventoryItem()` (the correct function used by all other banner handlers). Added a prefetch of `inventory_list` because `currentInventory` is empty on the dashboard.
|
||||||
|
- **"Product not found" when opening modal from a banner** — `currentInventory` is always empty on the dashboard; the inventory fetch now happens before opening the modal (same pattern as `editReviewItem` and `weighBannerItem`).
|
||||||
|
- **Expired banner on opened UHT milk** — The banner was showing "Expired!" instead of "Opened too long". Items with `opened_at` now display "Opened X days ago in [location]" in both the title and the banner detail.
|
||||||
|
- **Generic milk shelf life 4 → 7 days** — Milk without qualifiers (e.g. "Milk") was treated as fresh (4 days). Fresh milk is still handled explicitly (`latte fresco/intero/parzial/scremato` → 3 days); the generic case now defaults to 7 days (UHT default). Fix applied in both PHP (`database.php`) and JS (`app.js`).
|
||||||
|
- **Stale `opened_at` on sealed packages after split** — When a use operation splits a row into "whole sealed packages + opened fraction", the sealed-packages row was not clearing `opened_at`. All 3 split code paths now execute `opened_at = NULL` on the sealed row.
|
||||||
|
- **`inventory_update` was not recording transactions** — The quantity-edit modal updated inventory without creating transaction records. The quantity difference is now automatically recorded as `in` or `out` with a `[Manual correction]` note, preventing false positives in the anomaly detector.
|
||||||
|
- **False consumption anomalies after restocking** — The prediction baseline was using only the restock quantity (`restockQty`), ignoring pre-existing stock, causing `actual > expected` systematically. New baseline: `current_qty + consumed_since_last_restock`, which correctly reflects the real situation regardless of prior stock levels.
|
||||||
|
- **Anomaly banner firing on almost all products** — Two fixes:
|
||||||
|
1. `expected = 0` no longer generates a "more" anomaly (the model assumed you should have run out, but you restocked).
|
||||||
|
2. "More than expected" threshold raised to 400% (was 30%); "less than expected" threshold remains at 30%.
|
||||||
|
- **Expired section showing already-discarded products** — The `expired` query was missing `AND i.quantity > 0`; discarded products (qty=0) with a past expiry kept appearing. Query fixed and orphan rows cleaned from the DB.
|
||||||
|
- **Hardcoded Italian string `scade il` in banner** — Replaced with the correct i18n key.
|
||||||
|
- **Docker: `SQLSTATE[HY000][14] unable to open database file`** — `_ensureDataDir()` in `database.php` now creates the `data/` directory if missing and attempts `chmod(0775)` if not writable, resolving the error on freshly mounted Docker volumes.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Complete i18n** — Added ~25 missing translation keys for kiosk UI, Gemini responses, banners, scanner, shopping, and appliances across all 3 language files (`it.json`, `en.json`, `de.json`). Total: 934 keys per language.
|
||||||
|
|
||||||
|
## [1.7.8] - 2026-05-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Transfer to Recipes from chat** — When the Gemini Chef chat generates a recipe, a "📥 Transfer to Recipes" button appears. Pressing it triggers Gemini to convert the chat text into a complete structured JSON (title, meal, ingredients, steps); the backend enriches each ingredient with `product_id` and `location` via fuzzy-match (identical to `generateRecipe`); the recipe is saved and opens directly in the Recipes section with all "Use" buttons and full cooking mode.
|
||||||
|
- **"Open recipe" button** — After a successful transfer, the "📥 Transfer to Recipes" button transforms into "📖 Open recipe" (same DOM element), preventing overlap.
|
||||||
|
- **Create a recipe from an ingredient** — In the action panel of every inventory item, a "👨🍳 Create a recipe with this" button appears (teal, full width). Pressing it, Gemini generates a recipe using that ingredient as the star (same pipeline as `chatToRecipe`: inventory fuzzy-match enrichment, `meal=null`, 8192 token max).
|
||||||
|
- **Meal not auto-categorized** — Recipes generated from chat or from an ingredient are no longer auto-categorized (`meal` remains null); the meal tag in the UI is only shown when explicitly set.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Smart shopping: false "running low" alert** — If a product in grams/ml was nearly exhausted (e.g. Butter 30 g = 12%) but the same product was also available as a sealed package (Butter 1 pack = 99%), the system still flagged "running low". Now checks whether the `shopping_name` family has stock from other products; if so, the alert is suppressed.
|
||||||
|
- **Corrupted translation JSON** — The `action` section was duplicated in `de.json`, `en.json`, and `it.json`, causing JSON parse errors that blocked CI/CD. The spurious duplicate section has been removed.
|
||||||
|
|
||||||
|
## [1.7.7] - 2026-05-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Smart shopping family suppression** — The `recentlyExhausted` logic (products finished < 14 days ago) was incorrectly bypassing the `shopping_name` family suppression, causing false positives: products like Vanilla Yogurt appeared urgent even with 2 kg of Yogurt in stock. `recentlyExhausted` now only bypasses the token-based loose match; family suppression by `shopping_name` always applies.
|
||||||
|
- **Shelf-life pre-warming in cron** — The cron now calls `prewarmShelfLifeCache()` every 5 minutes, pre-loading via Gemini AI the shelf life of opened inventory items (max 5 items per cycle) before the user views them. This eliminates the noticeable delay on first click of "Opened on…".
|
||||||
|
|
||||||
|
## [1.7.6] - 2026-05-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`shopping_name` truncated (Piadina)** — The product "Piadine medie" had `shopping_name='Pi'` (truncated), preventing it from grouping correctly in its family. Fixed to `Piadina`.
|
||||||
|
- **Family merges in DB** — Grana Padano now under `Formaggio` (was a `Grana` singleton), Prosciutto cotto now under `Affettato`, Panna acida now under `Panna`.
|
||||||
|
- **`daily_rate` over the actual active period** — The daily consumption rate was using `first_in → now` as the window, diluting the rate with periods when the product was already exhausted (e.g. garlic exhausted at day 34 was calculated over 60+ days). Now uses `first_in → last_activity` (last purchase or last use), giving more accurate reorder predictions.
|
||||||
|
- **Stable anomaly dismiss key** — The dismiss key was using `product_id + round(expected)`, which changed with every new transaction, causing already-dismissed anomalies to reappear. Now uses `product_id + direction` (phantom/missing/untracked) — stable as long as the direction does not change.
|
||||||
|
- **Smart shopping: products exhausted < 14 days ago** — Products finished within the last 14 days are no longer suppressed by the token-coverage check or the shopping_name family check: if you just ran out, you probably want to restock regardless of equivalent stock on hand.
|
||||||
|
- **Chat pruning** — `chatSave()` now deletes messages beyond the 200 most recent after each save, preventing unbounded growth of the `chat_messages` table.
|
||||||
|
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- **Project renamed** from "Dispensa Manager" to **EverShelf**
|
||||||
|
- Contact email updated to `evershelfproject@gmail.com`
|
||||||
|
- Docker service, container, and volume renamed to `evershelf`
|
||||||
|
- SQLite database renamed from `dispensa.db` to `evershelf.db`
|
||||||
|
- All localStorage keys migrated: `dispensa_*` → `evershelf_*`
|
||||||
|
- Apache config file renamed to `evershelf.conf`
|
||||||
|
- CI workflow Docker image/container names updated
|
||||||
|
- App name updated in all translations (it, en, de)
|
||||||
|
- Navigation title updated to EverShelf across all languages
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Version badge (`v1.2.0`) in the app header
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- JS file truncation caused by `sed` in-place edit on large files
|
||||||
|
- Browser cache invalidation via bumped asset version strings (`?v=20260413a`)
|
||||||
|
|
||||||
## [1.0.0] - 2026-04-10
|
## [1.0.0] - 2026-04-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior they deem inappropriate, threatening, offensive, or harmful.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainer at **evershelfproject@gmail.com**. All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Contributing to Dispensa Manager
|
# Contributing to EverShelf
|
||||||
|
|
||||||
Thank you for your interest in contributing! This guide will help you get started.
|
Thank you for your interest in contributing! This guide will help you get started.
|
||||||
|
|
||||||
@@ -7,8 +7,8 @@ Thank you for your interest in contributing! This guide will help you get starte
|
|||||||
1. **Fork** the repository
|
1. **Fork** the repository
|
||||||
2. **Clone** your fork:
|
2. **Clone** your fork:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/YOUR_USERNAME/dispensa.git
|
git clone https://github.com/YOUR_USERNAME/EverShelf.git
|
||||||
cd dispensa
|
cd EverShelf
|
||||||
```
|
```
|
||||||
3. **Create a branch** from `develop`:
|
3. **Create a branch** from `develop`:
|
||||||
```bash
|
```bash
|
||||||
@@ -55,7 +55,7 @@ Translations are one of the easiest ways to contribute! Each language is a singl
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"app.title": "Dispensa Manager",
|
"app.title": "EverShelf",
|
||||||
"nav.dashboard": "Dashboard",
|
"nav.dashboard": "Dashboard",
|
||||||
"nav.inventory": "Inventario",
|
"nav.inventory": "Inventario",
|
||||||
...
|
...
|
||||||
@@ -110,7 +110,7 @@ node -c assets/js/app.js
|
|||||||
python3 -c "import json; json.load(open('translations/it.json'))"
|
python3 -c "import json; json.load(open('translations/it.json'))"
|
||||||
|
|
||||||
# Test Docker build
|
# Test Docker build
|
||||||
docker build -t dispensa-test .
|
docker build -t evershelf-test .
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📝 Pull Request Process
|
## 📝 Pull Request Process
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
FROM php:8.2-apache
|
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 \
|
RUN apt-get update && apt-get install -y \
|
||||||
libsqlite3-dev \
|
libsqlite3-dev \
|
||||||
libcurl4-openssl-dev \
|
libcurl4-openssl-dev \
|
||||||
&& docker-php-ext-install pdo_sqlite curl mbstring \
|
libonig-dev \
|
||||||
|
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/*
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Enable Apache mod_rewrite
|
# Enable Apache mod_rewrite and mod_headers
|
||||||
RUN a2enmod rewrite
|
RUN a2enmod rewrite headers
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
@@ -28,8 +33,8 @@ RUN [ ! -f /var/www/html/.env ] && cp /var/www/html/.env.example /var/www/html/.
|
|||||||
RUN echo '<Directory /var/www/html>\n\
|
RUN echo '<Directory /var/www/html>\n\
|
||||||
AllowOverride All\n\
|
AllowOverride All\n\
|
||||||
Require all granted\n\
|
Require all granted\n\
|
||||||
</Directory>' > /etc/apache2/conf-available/dispensa.conf \
|
</Directory>' > /etc/apache2/conf-available/evershelf.conf \
|
||||||
&& a2enconf dispensa
|
&& a2enconf evershelf
|
||||||
|
|
||||||
# Expose port 80
|
# Expose port 80
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
# 🏠 Dispensa Manager
|
# 🏠 EverShelf
|
||||||
|
|
||||||
> **Self-hosted pantry management system** — Track your food inventory, scan barcodes, get AI-powered recipe suggestions, and reduce waste.
|
> **Self-hosted pantry management system** — Track your food inventory, scan barcodes, get AI-powered recipe suggestions, and reduce waste.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<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)
|
[](LICENSE)
|
||||||
[](https://www.php.net/)
|
[](https://www.php.net/)
|
||||||
[](https://www.sqlite.org/)
|
[](https://www.sqlite.org/)
|
||||||
[](Dockerfile)
|
[](Dockerfile)
|
||||||
[](translations/)
|
[](translations/)
|
||||||
|
[](CHANGELOG.md)
|
||||||
<!--
|
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||||
<p align="center">
|
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||||
<img src="assets/img/screenshots/dashboard.png" alt="Dashboard Screenshot" width="320" />
|
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||||
</p>
|
[](https://github.com/dadaloop82/EverShelf/discussions)
|
||||||
-->
|
[](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,42 +38,86 @@
|
|||||||
|
|
||||||
### 📦 Inventory Management
|
### 📦 Inventory Management
|
||||||
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS
|
- **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
|
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
|
||||||
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
|
- **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
|
- **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)
|
### 🤖 AI-Powered (Google Gemini)
|
||||||
- **Expiry date reading** — Photograph a label and extract the expiry date automatically
|
- **Expiry date reading** — Photograph a label and extract the expiry date automatically
|
||||||
- **Product identification** — Point your camera at any product for instant recognition
|
- **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
|
- **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
|
### 🛒 Shopping List
|
||||||
- **Bring! integration** — Sync with the [Bring!](https://www.getbring.com/) shopping list app
|
- **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
|
- **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
|
- **Auto-add on depletion** — When a product reaches zero the app adds it to Bring! automatically, no confirmation needed
|
||||||
- **DupliClick integration** — Online grocery ordering (Gruppo Poli)
|
- **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
|
### 🍳 Cooking Mode
|
||||||
- **Step-by-step guidance** — Follow recipes with a hands-free cooking interface
|
- **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
|
- **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
|
### 📊 Dashboard
|
||||||
- **Waste tracking** — Monitor consumed vs. wasted products over 30 days
|
- **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
|
- **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
|
- **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
|
### 📱 Progressive Web App
|
||||||
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
||||||
- **Installable** — Add to home screen for a native app experience
|
- **Installable** — Add to home screen for a native app experience
|
||||||
- **Multi-device** — Settings and data sync across devices on the same server
|
- **Multi-device** — Settings and data sync across devices on the same server
|
||||||
|
|
||||||
|
### ⚖️ Smart Scale Integration (Add-on)
|
||||||
|
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
|
||||||
|
- **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
|
||||||
|
- **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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
@@ -71,8 +133,8 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repository
|
# 1. Clone the repository
|
||||||
git clone https://github.com/dadaloop82/dispensa.git
|
git clone https://github.com/dadaloop82/EverShelf.git
|
||||||
cd dispensa
|
cd EverShelf
|
||||||
|
|
||||||
# 2. Create configuration file
|
# 2. Create configuration file
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -88,8 +150,8 @@ docker compose up -d
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repository
|
# 1. Clone the repository
|
||||||
git clone https://github.com/dadaloop82/dispensa.git
|
git clone https://github.com/dadaloop82/EverShelf.git
|
||||||
cd dispensa
|
cd EverShelf
|
||||||
|
|
||||||
# 2. Create configuration file
|
# 2. Create configuration file
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -117,6 +179,13 @@ BRING_PASSWORD=your_password
|
|||||||
TTS_URL=http://your-home-assistant:8123/api/events/tts_speak
|
TTS_URL=http://your-home-assistant:8123/api/events/tts_speak
|
||||||
TTS_TOKEN=your_long_lived_token
|
TTS_TOKEN=your_long_lived_token
|
||||||
TTS_ENABLED=true
|
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
|
### Web Server Configuration
|
||||||
@@ -127,7 +196,7 @@ TTS_ENABLED=true
|
|||||||
The app works out of the box with Apache if placed in the web root or a subdirectory. Make sure `mod_rewrite` is enabled and `AllowOverride All` is set.
|
The app works out of the box with Apache if placed in the web root or a subdirectory. Make sure `mod_rewrite` is enabled and `AllowOverride All` is set.
|
||||||
|
|
||||||
```apache
|
```apache
|
||||||
<Directory /var/www/html/dispensa>
|
<Directory /var/www/html/evershelf>
|
||||||
AllowOverride All
|
AllowOverride All
|
||||||
Require all granted
|
Require all granted
|
||||||
</Directory>
|
</Directory>
|
||||||
@@ -142,7 +211,7 @@ The app works out of the box with Apache if placed in the web root or a subdirec
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name your-server.local;
|
server_name your-server.local;
|
||||||
root /var/www/html/dispensa;
|
root /var/www/html/evershelf;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
@@ -176,7 +245,7 @@ Set up a cron job for smart shopping predictions:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run every 5 minutes
|
# Run every 5 minutes
|
||||||
*/5 * * * * php /path/to/dispensa/api/cron_smart_shopping.php >> /path/to/dispensa/data/cron.log 2>&1
|
*/5 * * * * php /path/to/evershelf/api/cron_smart_shopping.php >> /path/to/evershelf/data/cron.log 2>&1
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backup (Optional)
|
### Backup (Optional)
|
||||||
@@ -185,7 +254,7 @@ The included `backup.sh` creates local daily backups of your database:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run daily at 3 AM
|
# Run daily at 3 AM
|
||||||
0 3 * * * /path/to/dispensa/backup.sh
|
0 3 * * * /path/to/evershelf/backup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -193,7 +262,7 @@ The included `backup.sh` creates local daily backups of your database:
|
|||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
dispensa/
|
evershelf/
|
||||||
├── index.html # Single-page application (SPA)
|
├── index.html # Single-page application (SPA)
|
||||||
├── manifest.json # PWA manifest
|
├── manifest.json # PWA manifest
|
||||||
├── .env.example # Configuration template
|
├── .env.example # Configuration template
|
||||||
@@ -211,9 +280,17 @@ dispensa/
|
|||||||
│ └── img/ # Static images
|
│ └── img/ # Static images
|
||||||
│
|
│
|
||||||
└── data/ # Runtime data (gitignored)
|
└── data/ # Runtime data (gitignored)
|
||||||
├── dispensa.db # SQLite database (auto-created)
|
├── evershelf.db # SQLite database (auto-created)
|
||||||
├── backups/ # Local DB backups
|
├── backups/ # Local DB backups
|
||||||
└── *.json # Token/cache files
|
└── *.json # Token/cache files
|
||||||
|
|
||||||
|
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
|
### API Endpoints
|
||||||
@@ -232,6 +309,9 @@ dispensa/
|
|||||||
| | `gemini_expiry` | POST | Read expiry date from photo |
|
| | `gemini_expiry` | POST | Read expiry date from photo |
|
||||||
| | `gemini_chat` | POST | Chat with AI assistant |
|
| | `gemini_chat` | POST | Chat with AI assistant |
|
||||||
| | `generate_recipe` | POST | Generate recipe from inventory |
|
| | `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 |
|
| **Shopping** | `bring_list` | GET | Get Bring! shopping list |
|
||||||
| | `bring_add` | POST | Add items to Bring! |
|
| | `bring_add` | POST | Add items to Bring! |
|
||||||
| | `smart_shopping` | GET | Smart shopping predictions |
|
| | `smart_shopping` | GET | Smart shopping predictions |
|
||||||
@@ -244,10 +324,12 @@ dispensa/
|
|||||||
|
|
||||||
- **Credentials** are stored in `.env` (server-side, never committed to Git)
|
- **Credentials** are stored in `.env` (server-side, never committed to Git)
|
||||||
- **Database** stays local — never pushed to remote repositories
|
- **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
|
- The API uses **parameterized SQL queries** (PDO prepared statements) against injection
|
||||||
- **Input validation** on all inventory operations (quantity bounds, location whitelist)
|
- **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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -255,7 +337,7 @@ dispensa/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run PHP's built-in server for local development
|
# Run PHP's built-in server for local development
|
||||||
php -S localhost:8080 -t /path/to/dispensa
|
php -S localhost:8080 -t /path/to/evershelf
|
||||||
|
|
||||||
# Check PHP syntax
|
# Check PHP syntax
|
||||||
php -l api/index.php
|
php -l api/index.php
|
||||||
@@ -268,10 +350,7 @@ The application uses no build tools — edit files directly and refresh.
|
|||||||
|
|
||||||
## 📋 Roadmap
|
## 📋 Roadmap
|
||||||
|
|
||||||
- [ ] Multi-language support (i18n)
|
|
||||||
- [ ] User authentication / multi-user support
|
- [ ] User authentication / multi-user support
|
||||||
- [ ] Docker container for easy deployment
|
|
||||||
- [x] REST API documentation (OpenAPI/Swagger) — see [docs/openapi.yaml](docs/openapi.yaml)
|
|
||||||
- [ ] Offline mode with service worker
|
- [ ] Offline mode with service worker
|
||||||
- [ ] Export/import inventory data
|
- [ ] Export/import inventory data
|
||||||
- [ ] Notification system (Telegram, email) for expiring products
|
- [ ] Notification system (Telegram, email) for expiring products
|
||||||
@@ -312,6 +391,15 @@ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE
|
|||||||
|
|
||||||
## 👨💻 Author
|
## 👨💻 Author
|
||||||
|
|
||||||
**Stimpfl Daniel** — [dadaloop82@gmail.com](mailto:dadaloop82@gmail.com)
|
**Stimpfl Daniel** — [evershelfproject@gmail.com](mailto:evershelfproject@gmail.com)
|
||||||
|
|
||||||
|
- Website: [evershelfproject.dadaloop.it](https://evershelfproject.dadaloop.it/)
|
||||||
- GitHub: [@dadaloop82](https://github.com/dadaloop82)
|
- GitHub: [@dadaloop82](https://github.com/dadaloop82)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📸 Screenshots
|
||||||
|
|
||||||
|
For a live walkthrough with real data and full AI enabled, visit the **[live demo](https://evershelfproject.dadaloop.it/demo)** — no installation required.
|
||||||
|
|
||||||
|
> Want to contribute a GIF or screenshots? See [CONTRIBUTING.md](CONTRIBUTING.md) — PRs welcome!
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
Only the latest released version of EverShelf receives security fixes.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---------|-----------|
|
||||||
|
| Latest (1.7.x) | ✅ |
|
||||||
|
| Older releases | ❌ |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
**Please do NOT open a public GitHub issue for security vulnerabilities.**
|
||||||
|
|
||||||
|
Report security issues privately via email:
|
||||||
|
|
||||||
|
**📧 evershelfproject@gmail.com**
|
||||||
|
|
||||||
|
Include:
|
||||||
|
- A description of the vulnerability
|
||||||
|
- Steps to reproduce
|
||||||
|
- Potential impact
|
||||||
|
- Your GitHub username (optional — for credit)
|
||||||
|
|
||||||
|
I aim to acknowledge reports within **48 hours** and release a fix within **7 days** for critical issues.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
EverShelf is a **self-hosted** application. The security model assumes:
|
||||||
|
|
||||||
|
- It runs on a trusted private network (home LAN)
|
||||||
|
- Access from the internet requires the user to set up their own authentication layer (e.g. reverse proxy with Authelia, Nginx `auth_basic`)
|
||||||
|
|
||||||
|
Out-of-scope issues:
|
||||||
|
- Vulnerabilities that require physical access to the server
|
||||||
|
- Issues only affecting users who have not followed the security recommendations in the README
|
||||||
|
- Denial-of-service attacks on the demo server
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
- API keys stored server-side in `.env`, never sent to the browser
|
||||||
|
- `get_settings` returns only boolean flags (`gemini_key_set`), never raw key values
|
||||||
|
- Optional `SETTINGS_TOKEN` protects write operations (`hash_equals` to prevent timing attacks)
|
||||||
|
- `DEMO_MODE=true` blocks all write operations at the router level
|
||||||
|
- Parameterized SQL queries (PDO prepared statements) throughout
|
||||||
|
- Input validation and length limits on all user-supplied fields
|
||||||
|
- `.env` and `data/` directories denied via web server config (see README)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* Cron: pre-compute smart shopping list and save to cache.
|
* Cron: pre-compute smart shopping list and save to cache.
|
||||||
* Install with: crontab -e
|
* Install with: crontab -e
|
||||||
* *\/5 * * * * php /var/www/html/dispensa/api/cron_smart_shopping.php >> /var/www/html/dispensa/data/cron.log 2>&1
|
* *\/5 * * * * php /var/www/html/evershelf/api/cron_smart_shopping.php >> /var/www/html/evershelf/data/cron.log 2>&1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Only allow CLI execution — block HTTP access
|
// Only allow CLI execution — block HTTP access
|
||||||
@@ -39,8 +39,50 @@ try {
|
|||||||
throw new RuntimeException('Cannot write cache file: ' . CACHE_FILE);
|
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) {
|
} 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);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,54 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Dispensa Manager - Database initialization, schema, and migrations.
|
* EverShelf - Database initialization, schema, and migrations.
|
||||||
* Uses SQLite with WAL journal mode for concurrent read/write performance.
|
* Uses SQLite with WAL journal mode for concurrent read/write performance.
|
||||||
*
|
*
|
||||||
* @author Stimpfl Daniel <dadaloop82@gmail.com>
|
* @author Stimpfl Daniel <evershelfproject@gmail.com>
|
||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
define('DB_PATH', __DIR__ . '/../data/dispensa.db');
|
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 {
|
function getDB(): PDO {
|
||||||
|
_ensureDataDir();
|
||||||
$isNew = !file_exists(DB_PATH);
|
$isNew = !file_exists(DB_PATH);
|
||||||
$db = new PDO('sqlite:' . DB_PATH);
|
$db = new PDO('sqlite:' . DB_PATH);
|
||||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
$db->exec("PRAGMA journal_mode=WAL");
|
$db->exec("PRAGMA journal_mode=WAL");
|
||||||
$db->exec("PRAGMA foreign_keys=ON");
|
$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) {
|
if ($isNew) {
|
||||||
initializeDB($db);
|
initializeDB($db);
|
||||||
@@ -39,6 +72,7 @@ function initializeDB(PDO $db): void {
|
|||||||
unit TEXT DEFAULT 'pz',
|
unit TEXT DEFAULT 'pz',
|
||||||
default_quantity REAL DEFAULT 1,
|
default_quantity REAL DEFAULT 1,
|
||||||
notes TEXT DEFAULT '',
|
notes TEXT DEFAULT '',
|
||||||
|
shopping_name TEXT DEFAULT '',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -61,6 +95,7 @@ function initializeDB(PDO $db): void {
|
|||||||
quantity REAL NOT NULL,
|
quantity REAL NOT NULL,
|
||||||
location TEXT NOT NULL DEFAULT 'dispensa',
|
location TEXT NOT NULL DEFAULT 'dispensa',
|
||||||
notes TEXT DEFAULT '',
|
notes TEXT DEFAULT '',
|
||||||
|
undone INTEGER DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
@@ -70,6 +105,11 @@ function initializeDB(PDO $db): void {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_inventory_location ON inventory(location);
|
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_product ON transactions(product_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at);
|
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);
|
||||||
");
|
");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +118,12 @@ function migrateDB(PDO $db): void {
|
|||||||
$cols = $db->query("PRAGMA table_info(products)")->fetchAll();
|
$cols = $db->query("PRAGMA table_info(products)")->fetchAll();
|
||||||
$colNames = array_column($cols, 'name');
|
$colNames = array_column($cols, 'name');
|
||||||
if (!in_array('package_unit', $colNames)) {
|
if (!in_array('package_unit', $colNames)) {
|
||||||
$db->exec("ALTER TABLE products ADD COLUMN package_unit TEXT DEFAULT ''");
|
try { $db->exec("ALTER TABLE products ADD COLUMN package_unit TEXT DEFAULT ''"); }
|
||||||
|
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||||
|
}
|
||||||
|
if (!in_array('shopping_name', $colNames)) {
|
||||||
|
try { $db->exec("ALTER TABLE products ADD COLUMN shopping_name TEXT DEFAULT ''"); }
|
||||||
|
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate transactions CHECK constraint to allow 'waste' type
|
// Migrate transactions CHECK constraint to allow 'waste' type
|
||||||
@@ -101,6 +146,8 @@ function migrateDB(PDO $db): void {
|
|||||||
$db->exec("DROP TABLE transactions_old");
|
$db->exec("DROP TABLE transactions_old");
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id)");
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at)");
|
||||||
|
$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 ---
|
// --- New shared tables ---
|
||||||
@@ -155,25 +202,53 @@ function migrateDB(PDO $db): void {
|
|||||||
// Add opened_at column to inventory if missing
|
// Add opened_at column to inventory if missing
|
||||||
if (!in_array('opened_at', $invColNames)) {
|
if (!in_array('opened_at', $invColNames)) {
|
||||||
$db->exec("ALTER TABLE inventory ADD COLUMN opened_at DATETIME DEFAULT NULL");
|
$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);
|
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)
|
// 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();
|
$migrated = $db->query("SELECT value FROM app_settings WHERE key = 'migration_fridge_expiry_v1'")->fetchColumn();
|
||||||
if (!$migrated) {
|
if (!$migrated) {
|
||||||
recalcSealedFridgeExpiry($db);
|
recalcSealedFridgeExpiry($db);
|
||||||
$db->exec("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('migration_fridge_expiry_v1', '1')");
|
$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:
|
* An item is considered opened if:
|
||||||
* - conf unit with fractional quantity
|
* - conf unit with fractional quantity
|
||||||
* - weight/volume unit (g,kg,ml,l) with quantity < default_quantity
|
* - weight/volume unit (g,kg,ml,l) with quantity < default_quantity
|
||||||
* Uses updated_at as the approximate opened_at date.
|
* 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 {
|
function backfillOpenedItems(PDO $db): void {
|
||||||
$stmt = $db->query("
|
$stmt = $db->query("
|
||||||
@@ -181,7 +256,7 @@ function backfillOpenedItems(PDO $db): void {
|
|||||||
p.name, p.category, p.unit, p.default_quantity
|
p.name, p.category, p.unit, p.default_quantity
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
JOIN products p ON i.product_id = p.id
|
JOIN products p ON i.product_id = p.id
|
||||||
WHERE i.quantity > 0
|
WHERE i.quantity > 0 AND i.location = 'frigo'
|
||||||
");
|
");
|
||||||
$rows = $stmt->fetchAll();
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
@@ -200,15 +275,9 @@ function backfillOpenedItems(PDO $db): void {
|
|||||||
|
|
||||||
if (!$isOpened) continue;
|
if (!$isOpened) continue;
|
||||||
|
|
||||||
$openedAt = $row['updated_at'];
|
// Only set opened_at — do NOT touch expiry_date (manufacturer date is preserved)
|
||||||
$openedDays = estimateOpenedExpiryDaysPHP($row['name'], $row['category'], $row['location']);
|
$upd = $db->prepare("UPDATE inventory SET opened_at = ? WHERE id = ? AND opened_at IS NULL");
|
||||||
if ($row['vacuum_sealed']) $openedDays = (int)round($openedDays * 1.5);
|
$upd->execute([$row['updated_at'], $row['id']]);
|
||||||
|
|
||||||
// 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']]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,8 +314,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;
|
if (preg_match('/\b(lenticchie|ceci|fagioli|piselli)\b/', $n) && !preg_match('/\b(cotto|vapore|scatola)\b/', $n)) return 365;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── D: Freezer ───────────────────────────────────────────────────────
|
// ── D: Freezer — per-product estimates (USDA/EFSA guidelines) ───────
|
||||||
if ($loc === 'freezer') return 90;
|
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 ─────
|
// ── E: Pantry/dispensa — specific products then generic fallback ─────
|
||||||
if ($loc !== 'frigo') {
|
if ($loc !== 'frigo') {
|
||||||
@@ -257,14 +355,21 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
|||||||
if (preg_match('/\bpane\b/', $n)) return 4;
|
if (preg_match('/\bpane\b/', $n)) return 4;
|
||||||
// Specific jarred tomato sauce in pantry (opened, not refrigerated)
|
// Specific jarred tomato sauce in pantry (opened, not refrigerated)
|
||||||
if (preg_match('/salsa\s+di\s+(pomodoro|pronta)/', $n)) return 5;
|
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 ──────────────────────────────
|
// ── F: Fridge — short-life perishables ──────────────────────────────
|
||||||
if (preg_match('/latte\s+(fresco|intero|parzial|scremato)/', $n)) return 3;
|
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('/latte\s+(uht|a\s+lunga)/', $n)) return 7;
|
||||||
if (preg_match('/\blatte\b/', $n)) return 4;
|
// Long-life mountain/brand milks stored in pantry before use (UHT)
|
||||||
if (preg_match('/\byogurt\b/', $n)) return 5;
|
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('/mozzarella|burrata|stracciatella/', $n)) return 3;
|
||||||
if (preg_match('/philadelphia|spalmabile/', $n)) return 7;
|
if (preg_match('/philadelphia|spalmabile/', $n)) return 7;
|
||||||
if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5;
|
if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5;
|
||||||
@@ -277,25 +382,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('/\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('/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('/\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(succo|spremuta)\b/', $n)) return 3;
|
||||||
if (preg_match('/\b(birra|beer)\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('/\bvino\b/', $n)) return 5;
|
||||||
if (preg_match('/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/', $n)) return 4;
|
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
|
// Fruit in fridge (opened pack, not necessarily cut)
|
||||||
if (preg_match('/\bavocado\b/', $n)) return 2;
|
if (preg_match('/\bavocado\b/', $n)) return 3;
|
||||||
if (preg_match('/\b(banana|banane|fragola|lampone|pesca|albicocca|ciliegia|mango|papaya)\b/', $n)) return 2;
|
if (preg_match('/\b(fragola|fragole|lampone|lamponi|mirtillo|mirtilli|mora|more)\b/', $n)) return 4;
|
||||||
if (preg_match('/\b(mela|pera|nettarina|prugna|kiwi|ananas|uva|melone|anguria)\b/', $n)) return 3;
|
if (preg_match('/\b(banana|banane|pesca|pesche|albicocca|albicocche|ciliegia|ciliegie|mango|papaya)\b/', $n)) return 4;
|
||||||
if (preg_match('/\b(arancia|mandarino|pompelmo|clementina|limone)\b/', $n)) return 3; // cut citrus
|
if (preg_match('/\b(mela|mele|pera|pere|nettarina|prugna|kiwi|ananas|uva|melone|anguria)\b/', $n)) return 5;
|
||||||
// Vegetables opened/cut in fridge
|
if (preg_match('/\b(arancia|arance|mandarino|mandarini|pompelmo|clementina|limone|limoni)\b/', $n)) return 7;
|
||||||
if (preg_match('/\b(zucchina|zucchine|melanzana|pomodor)\b/', $n)) return 3;
|
// Vegetables in fridge (opened pack)
|
||||||
if (preg_match('/\b(peperone|peperoni)\b/', $n)) return 3;
|
if (preg_match('/\b(zucchina|zucchine|melanzana|melanzane|pomodor)\b/', $n)) return 5;
|
||||||
if (preg_match('/\b(broccolo|broccoli|cavolfiore|cavolo)\b/', $n)) return 3;
|
if (preg_match('/\b(peperone|peperoni)\b/', $n)) return 5;
|
||||||
if (preg_match('/\bsedano\b|\bfinocchio\b/', $n)) return 3;
|
if (preg_match('/\b(broccolo|broccoli|cavolfiore|cavolo)\b/', $n)) return 4;
|
||||||
if (preg_match('/\b(cipolla|cipolle|cipollotto|scalogno|porro)\b/', $n)) return 4;
|
if (preg_match('/\bsedano\b|\bfinocchio\b/', $n)) return 5;
|
||||||
if (preg_match('/\b(carota|carote)\b/', $n)) return 5;
|
if (preg_match('/\b(cipolla|cipolle|cipollotto|scalogno|porro)\b/', $n)) return 6;
|
||||||
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 3; // cooked/cut potato
|
if (preg_match('/\b(carota|carote)\b/', $n)) return 7;
|
||||||
if (preg_match('/\baglio\b/', $n)) return 10;
|
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 ─────────────────────────
|
// ── G: Fridge condiments — medium shelf-life ─────────────────────────
|
||||||
if (preg_match('/maionese|mayo|mayon/', $n)) return 90;
|
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 |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -1,23 +1,23 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Daily backup of Dispensa database (local only)
|
# Daily backup of EverShelf database (local only)
|
||||||
# The database is NOT pushed to remote repositories.
|
# The database is NOT pushed to remote repositories.
|
||||||
# Runs via cron: creates a local timestamped backup copy
|
# Runs via cron: creates a local timestamped backup copy
|
||||||
#
|
#
|
||||||
# Example crontab entry:
|
# Example crontab entry:
|
||||||
# 0 3 * * * /var/www/html/dispensa/backup.sh
|
# 0 3 * * * /var/www/html/evershelf/backup.sh
|
||||||
|
|
||||||
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
BACKUP_DIR="${INSTALL_DIR}/data/backups"
|
BACKUP_DIR="${INSTALL_DIR}/data/backups"
|
||||||
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
DB_FILE="${INSTALL_DIR}/data/dispensa.db"
|
DB_FILE="${INSTALL_DIR}/data/evershelf.db"
|
||||||
if [ ! -f "$DB_FILE" ]; then
|
if [ ! -f "$DB_FILE" ]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
DATE=$(date '+%Y-%m-%d_%H%M')
|
DATE=$(date '+%Y-%m-%d_%H%M')
|
||||||
cp "$DB_FILE" "${BACKUP_DIR}/dispensa_${DATE}.db"
|
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
|
||||||
|
|
||||||
# Keep only the last 7 backups
|
# Keep only the last 7 backups
|
||||||
ls -t "${BACKUP_DIR}"/dispensa_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
|
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
|
{}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
dispensa:
|
evershelf:
|
||||||
build: .
|
build: .
|
||||||
container_name: dispensa
|
container_name: evershelf
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
volumes:
|
volumes:
|
||||||
# Persist database and runtime data
|
# Persist database and runtime data
|
||||||
- dispensa_data:/var/www/html/data
|
- evershelf_data:/var/www/html/data
|
||||||
# Mount your local .env configuration
|
# Mount your local .env configuration
|
||||||
- ./.env:/var/www/html/.env:ro
|
- ./.env:/var/www/html/.env:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -14,5 +14,5 @@ services:
|
|||||||
- TZ=Europe/Rome
|
- TZ=Europe/Rome
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dispensa_data:
|
evershelf_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
openapi: "3.1.0"
|
openapi: "3.1.0"
|
||||||
info:
|
info:
|
||||||
title: Dispensa Manager API
|
title: EverShelf API
|
||||||
description: |
|
description: |
|
||||||
REST API for Dispensa Manager — a self-hosted pantry management system.
|
REST API for EverShelf — a self-hosted pantry management system.
|
||||||
All endpoints use the query parameter `action` to determine the operation.
|
All endpoints use the query parameter `action` to determine the operation.
|
||||||
|
|
||||||
**Base URL:** `api/index.php?action={action_name}`
|
**Base URL:** `api/index.php?action={action_name}`
|
||||||
@@ -11,10 +11,10 @@ info:
|
|||||||
- General: 120 requests/minute
|
- General: 120 requests/minute
|
||||||
- AI endpoints: 15 requests/minute
|
- AI endpoints: 15 requests/minute
|
||||||
- Login endpoints: 5 requests/minute
|
- Login endpoints: 5 requests/minute
|
||||||
version: "1.0.0"
|
version: "1.2.0"
|
||||||
contact:
|
contact:
|
||||||
name: Stimpfl Daniel
|
name: Stimpfl Daniel
|
||||||
email: dadaloop82@gmail.com
|
email: evershelfproject@gmail.com
|
||||||
license:
|
license:
|
||||||
name: MIT
|
name: MIT
|
||||||
url: https://opensource.org/licenses/MIT
|
url: https://opensource.org/licenses/MIT
|
||||||
@@ -500,39 +500,6 @@ paths:
|
|||||||
"200":
|
"200":
|
||||||
description: Recipe deleted
|
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:
|
/index.php?action=tts_proxy:
|
||||||
post:
|
post:
|
||||||
summary: Proxy TTS request to external endpoint
|
summary: Proxy TTS request to external endpoint
|
||||||
@@ -685,7 +652,5 @@ tags:
|
|||||||
description: Recipe storage
|
description: Recipe storage
|
||||||
- name: Settings
|
- name: Settings
|
||||||
description: Application and server settings
|
description: Application and server settings
|
||||||
- name: DupliClick
|
|
||||||
description: DupliClick online shopping integration
|
|
||||||
- name: TTS
|
- name: TTS
|
||||||
description: Text-to-Speech proxy
|
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,138 @@
|
|||||||
|
# 📺 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.6.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 **built-in BLE scale gateway** as an integrated foreground service — no external app required
|
||||||
|
- 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 — Smart Scale
|
||||||
|
If you have a Bluetooth LE smart scale, configure it here:
|
||||||
|
1. Tap **"Yes, I have a scale"** — the app scans for nearby BLE devices
|
||||||
|
2. Tap your scale in the list (devices most likely to be scales are marked with ⭐)
|
||||||
|
3. On selection, the app automatically writes `scale_enabled=true` and `scale_gateway_url=ws://127.0.0.1:8765` to your EverShelf server
|
||||||
|
|
||||||
|
The BLE gateway runs as a built-in foreground service — **no external APK needed**.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### "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 **🍳 Recipes** → **Generate Recipe** 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.13 (2026-05-16)
|
||||||
|
- **Critical fix:** Fresh-install crash resolved — `transactions` schema was missing the `undone` column, causing a database failure on every new installation
|
||||||
|
- **Fix:** Race condition in DB migrations no longer causes `duplicate column name` errors on concurrent first requests
|
||||||
|
|
||||||
|
### v1.7.12 (2026-05-13)
|
||||||
|
- "Use first" banner now shows opening date and location instead of a confusing calculated expiry
|
||||||
|
- "Use All / Done" in recipes no longer deletes the inventory row — uses exact quantity instead
|
||||||
|
- Scan page fully redesigned: 2× zoom, torch, camera flip, 3 input tabs, AI Number OCR, recent products chips
|
||||||
|
- Anomaly detection: false positives eliminated (untracked direction removed, minimum 5 txn + 7-day span)
|
||||||
|
- AI price estimation for each Bring! shopping item with real-time dashboard total badge
|
||||||
|
- Kiosk v1.6.0: BLE scale gateway is now built-in — no separate APK needed
|
||||||
|
- Complete i18n: 934 keys per language
|
||||||
|
|
||||||
|
→ 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,162 @@
|
|||||||
|
# ⚠️ Scale Gateway — Deprecated
|
||||||
|
|
||||||
|
> **As of EverShelf Kiosk v1.6.0, BLE scale support is fully integrated into the Kiosk app.**
|
||||||
|
> You no longer need to install or configure this separate gateway.
|
||||||
|
>
|
||||||
|
> 📱 **Using the EverShelf Kiosk app?** → See [Android Kiosk](Android-Kiosk) — configure your scale in Step 5 of the setup wizard.
|
||||||
|
>
|
||||||
|
> 💻 **Not using the kiosk app?** The legacy gateway APK below still works for non-kiosk setups, but receives no new updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scale Gateway (legacy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### 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 **"Yes, I have a scale"**, 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 **"Exit"** to confirm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 |