Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cb4ae13f1 |
@@ -1,5 +0,0 @@
|
||||
node_modules/
|
||||
.git/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
@@ -1,178 +1,25 @@
|
||||
# EverShelf — Configuration
|
||||
# Copy this file to .env and fill in your values:
|
||||
# cp .env.example .env
|
||||
#
|
||||
# All settings here can also be changed from the in-app Settings screen and
|
||||
# will be written back to this file automatically.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# EverShelf - Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
# cp .env.example .env
|
||||
|
||||
# ── AI ────────────────────────────────────────────────────────────────────────
|
||||
# Google Gemini API key (required for AI features: expiry reading, recipe gen, …)
|
||||
# Get one free at: https://aistudio.google.com/app/apikey
|
||||
# Google Gemini AI API Key (required for AI features)
|
||||
# Get one at: https://aistudio.google.com/app/apikey
|
||||
GEMINI_API_KEY=
|
||||
|
||||
# ── Shopping list (Bring!) ────────────────────────────────────────────────────
|
||||
# Credentials for the Bring! app (optional — app works without it)
|
||||
# Bring! Shopping List credentials (optional)
|
||||
# Sign up at: https://www.getbring.com/
|
||||
BRING_EMAIL=
|
||||
BRING_PASSWORD=
|
||||
|
||||
# ── Text-to-Speech (TTS) ─────────────────────────────────────────────────────
|
||||
# Works with Home Assistant, a local TTS server, or any HTTP endpoint.
|
||||
# TTS_ENABLED: master switch (true/false)
|
||||
TTS_ENABLED=false
|
||||
# TTS_URL: endpoint that receives the text payload
|
||||
# TTS (Text-to-Speech) for cooking mode voice guidance (optional)
|
||||
# Works with Home Assistant, or any HTTP endpoint that accepts text
|
||||
TTS_URL=
|
||||
# TTS_TOKEN: Authorization token sent as Bearer header (or empty)
|
||||
TTS_TOKEN=
|
||||
# TTS_METHOD: HTTP method (POST or GET)
|
||||
TTS_METHOD=POST
|
||||
# TTS_AUTH_TYPE: how the token is sent (bearer | basic | none)
|
||||
TTS_AUTH_TYPE=bearer
|
||||
# TTS_CONTENT_TYPE: request Content-Type header
|
||||
TTS_CONTENT_TYPE=application/json
|
||||
# TTS_PAYLOAD_KEY: JSON key that carries the text (e.g. "message", "text")
|
||||
TTS_PAYLOAD_KEY=message
|
||||
# TTS_ENGINE: preferred browser TTS engine ('browser', 'server', 'custom') — optional
|
||||
TTS_ENGINE=
|
||||
# TTS_RATE / TTS_PITCH: speech rate and pitch multipliers (1 = normal)
|
||||
TTS_RATE=1
|
||||
TTS_PITCH=1
|
||||
# TTS_AUTH_HEADER_NAME / VALUE: custom HTTP header for authentication (optional)
|
||||
TTS_AUTH_HEADER_NAME=
|
||||
TTS_AUTH_HEADER_VALUE=
|
||||
# TTS_EXTRA_FIELDS: additional JSON fields as key=value pairs, comma-separated (optional)
|
||||
TTS_EXTRA_FIELDS=
|
||||
TTS_ENABLED=false
|
||||
|
||||
# ── User preferences ─────────────────────────────────────────────────────────
|
||||
# These mirror the toggle switches in the Settings screen.
|
||||
DEFAULT_PERSONS=1
|
||||
PREF_VELOCE=false
|
||||
PREF_POCAFAME=false
|
||||
PREF_SCADENZE=true
|
||||
PREF_HEALTHY=false
|
||||
PREF_OPENED=true
|
||||
PREF_ZEROWASTE=false
|
||||
# Dietary restrictions shown to the AI (e.g. "vegetariano,senza glutine")
|
||||
DIETARY=
|
||||
|
||||
# ── Appliances ────────────────────────────────────────────────────────────────
|
||||
# Comma-separated list of appliances available in your kitchen.
|
||||
# Used by the AI when generating recipes.
|
||||
APPLIANCES=Forno,Microonde,Friggitrice ad aria,Pentola a pressione
|
||||
|
||||
# ── Camera ───────────────────────────────────────────────────────────────────
|
||||
# Default camera for barcode scanning ('environment' = rear, 'user' = front)
|
||||
CAMERA_FACING=environment
|
||||
|
||||
# ── Smart Kitchen Scale ───────────────────────────────────────────────────────
|
||||
# SCALE_ENABLED: enables the scale integration
|
||||
SCALE_ENABLED=false
|
||||
# SCALE_GATEWAY_URL: address of the EverShelf Scale Gateway (Android app)
|
||||
SCALE_GATEWAY_URL=
|
||||
|
||||
# ── Meal Plan ────────────────────────────────────────────────────────────────
|
||||
# MEAL_PLAN_ENABLED: show the weekly meal planner tab in Settings
|
||||
MEAL_PLAN_ENABLED=false
|
||||
|
||||
# ── Screensaver (kiosk / tablet mode) ────────────────────────────────────────
|
||||
SCREENSAVER_ENABLED=false
|
||||
# SCREENSAVER_TIMEOUT: inactivity seconds before screensaver activates (default 5 min)
|
||||
SCREENSAVER_TIMEOUT=300
|
||||
|
||||
# ── Price estimates ───────────────────────────────────────────────────────────
|
||||
# PRICE_ENABLED: show AI-estimated price column on the shopping list
|
||||
PRICE_ENABLED=false
|
||||
# PRICE_COUNTRY: country used for price context (e.g. "Italia", "Germany")
|
||||
PRICE_COUNTRY=Italia
|
||||
# PRICE_CURRENCY: ISO 4217 currency code (e.g. EUR, USD, GBP)
|
||||
PRICE_CURRENCY=EUR
|
||||
# PRICE_UPDATE_MONTHS: how many months to cache a price before re-fetching (default 3)
|
||||
PRICE_UPDATE_MONTHS=3
|
||||
|
||||
# ── Cleanup / retention ──────────────────────────────────────────────────────
|
||||
# RECIPE_RETENTION_DAYS: delete auto-generated recipe plans older than N days
|
||||
RECIPE_RETENTION_DAYS=7
|
||||
# TRANSACTION_RETENTION_DAYS: keep stock transaction history for N days.
|
||||
# Smart Shopping uses this history to compute purchase frequencies.
|
||||
# WARNING: values below 30 will cause the shopping list to appear nearly empty.
|
||||
# Minimum enforced at runtime: 30 days.
|
||||
TRANSACTION_RETENTION_DAYS=90
|
||||
|
||||
# ── Local Backup ─────────────────────────────────────────────────────────────
|
||||
# BACKUP_ENABLED: run a daily incremental backup via cron (true/false)
|
||||
BACKUP_ENABLED=true
|
||||
# BACKUP_RETENTION_DAYS: keep local backups for N days (minimum 1)
|
||||
BACKUP_RETENTION_DAYS=3
|
||||
|
||||
# ── Google Drive Backup ───────────────────────────────────────────────────────
|
||||
# GDRIVE_ENABLED: upload the daily backup to Google Drive (requires a service account)
|
||||
GDRIVE_ENABLED=false
|
||||
#
|
||||
# Setup steps:
|
||||
# 1. Create a Google Cloud project and enable the Drive API
|
||||
# 2. Create a Service Account and download the JSON key
|
||||
# 3. Create a Drive folder and share it with the service account email
|
||||
# 4. Paste the JSON content below (or set GDRIVE_SERVICE_ACCOUNT_FILE to the path)
|
||||
# 5. Set GDRIVE_FOLDER_ID to the Drive folder ID (from its URL)
|
||||
#
|
||||
# GDRIVE_SERVICE_ACCOUNT_JSON: full JSON content of the service account key
|
||||
GDRIVE_SERVICE_ACCOUNT_JSON=
|
||||
# GDRIVE_SERVICE_ACCOUNT_FILE: alternative — path to the service account JSON file
|
||||
GDRIVE_SERVICE_ACCOUNT_FILE=
|
||||
# GDRIVE_FOLDER_ID: ID of the Drive folder where backups will be stored
|
||||
GDRIVE_FOLDER_ID=
|
||||
# GDRIVE_RETENTION_DAYS: delete Drive backups older than N days (0 = keep all)
|
||||
GDRIVE_RETENTION_DAYS=30
|
||||
|
||||
# ── Security ─────────────────────────────────────────────────────────────────
|
||||
# API_TOKEN: when set, all API calls require header X-API-Token (or ?api_token= for HA).
|
||||
# SETTINGS_TOKEN: legacy alias — use API_TOKEN for new installs.
|
||||
API_TOKEN=
|
||||
SETTINGS_TOKEN=
|
||||
|
||||
# CORS_ORIGIN: comma-separated allowed origins (empty = same-origin only, no wildcard)
|
||||
CORS_ORIGIN=
|
||||
|
||||
# GitHub automatic issue reporting (encrypted storage recommended)
|
||||
# Option A — plain ( .env is gitignored ):
|
||||
# GH_ISSUE_TOKEN=ghp_...
|
||||
# Option B — encrypted (php scripts/encrypt-gh-token.php 'ghp_...' 'secret-key'):
|
||||
GH_ISSUE_TOKEN=
|
||||
GH_ISSUE_TOKEN_ENC=
|
||||
GH_ISSUE_TOKEN_KEY=
|
||||
|
||||
# NOTE: Run `php scripts/migrate-env-security.php` once after upgrading to migrate legacy tokens.
|
||||
|
||||
# INSTANCE_NAME: display name for this EverShelf instance (used by the HA integration
|
||||
# for Zeroconf discovery label and device name in Home Assistant).
|
||||
# Defaults to the server hostname if left empty.
|
||||
INSTANCE_NAME=
|
||||
|
||||
# ── Home Assistant Integration ────────────────────────────────────────────────
|
||||
# All HA settings can also be configured from the Settings → 🏠 tab.
|
||||
#
|
||||
# HA_ENABLED: master switch for all HA features (webhooks, TTS, sensors)
|
||||
HA_ENABLED=false
|
||||
# HA_URL: base URL of your HA instance — no trailing slash
|
||||
# Examples: http://homeassistant.local:8123 or http://192.168.1.50:8123
|
||||
HA_URL=
|
||||
# HA_TOKEN: Long-Lived Access Token (HA Profile → Security → Long-Lived Access Tokens)
|
||||
HA_TOKEN=
|
||||
# HA_TTS_ENTITY: media_player entity for recipe step TTS (e.g. media_player.living_room)
|
||||
HA_TTS_ENTITY=
|
||||
# HA_WEBHOOK_ID: ID of an HA automation's Webhook trigger
|
||||
HA_WEBHOOK_ID=
|
||||
# HA_WEBHOOK_EVENTS: comma-separated events to fire webhooks for
|
||||
# Available: expiry, shopping_add, stock_update, barcode_scan
|
||||
HA_WEBHOOK_EVENTS=expiry,shopping_add,stock_update
|
||||
# HA_NOTIFY_SERVICE: HA notify service for push alerts (e.g. notify.mobile_app_my_phone)
|
||||
HA_NOTIFY_SERVICE=
|
||||
# HA_EXPIRY_DAYS: days before expiry to trigger expiry alert (default 3)
|
||||
HA_EXPIRY_DAYS=3
|
||||
|
||||
# ── Developer / demo ─────────────────────────────────────────────────────────
|
||||
# DEMO_MODE: when true, all write operations are blocked (for public demos)
|
||||
DEMO_MODE=false
|
||||
|
||||
# CRON_LOG_MAX_BYTES: rotate data/cron.log when larger (default 524288 = 512 KB)
|
||||
CRON_LOG_MAX_BYTES=524288
|
||||
# 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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
ko_fi: evershelfproject
|
||||
ko_fi: dadaloop82
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
name: Build & Release Kiosk APK
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -20,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
@@ -29,7 +26,7 @@ jobs:
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
gradle-version: '8.6'
|
||||
|
||||
@@ -37,10 +34,8 @@ jobs:
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep 'versionName' evershelf-kiosk/app/build.gradle.kts | grep -oP '"\K[^"]+')
|
||||
VCODE=$(grep 'versionCode' evershelf-kiosk/app/build.gradle.kts | grep -oP '\d+')
|
||||
echo "name=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "code=$VCODE" >> "$GITHUB_OUTPUT"
|
||||
echo "Kiosk version: $VERSION (versionCode $VCODE)"
|
||||
echo "Kiosk version: $VERSION"
|
||||
|
||||
- name: Build debug APK
|
||||
run: gradle assembleDebug --no-daemon
|
||||
@@ -77,21 +72,7 @@ jobs:
|
||||
sleep 3
|
||||
gh release create kiosk-latest \
|
||||
--title "EverShelf Kiosk Latest" \
|
||||
--notes "Auto alias → kiosk-${{ steps.version.outputs.name }} (versionCode ${{ steps.version.outputs.code }})" \
|
||||
--notes "Alias automatico → kiosk-${{ steps.version.outputs.name }}" \
|
||||
--prerelease \
|
||||
artifacts/evershelf-kiosk.apk
|
||||
|
||||
- name: Publish APK to releases/ for LAN OTA
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WORKFLOW_PAT || secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cp artifacts/evershelf-kiosk.apk releases/evershelf-kiosk.apk
|
||||
printf '{"version":"%s","version_code":%s}\n' \
|
||||
"${{ steps.version.outputs.name }}" "${{ steps.version.outputs.code }}" \
|
||||
> releases/kiosk-version.json
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add releases/evershelf-kiosk.apk releases/kiosk-version.json
|
||||
git diff --staged --quiet || git commit -m "chore(kiosk): publish APK v${{ steps.version.outputs.name }} for LAN OTA"
|
||||
git push origin HEAD:${{ github.ref_name }}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
gradle-version: '8.4'
|
||||
|
||||
|
||||
@@ -6,15 +6,12 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
lint-php:
|
||||
name: PHP Syntax Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
@@ -30,7 +27,7 @@ jobs:
|
||||
name: JavaScript Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check JS syntax
|
||||
run: |
|
||||
@@ -40,21 +37,10 @@ jobs:
|
||||
name: Docker Build Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
set -e
|
||||
for attempt in 1 2 3; do
|
||||
echo "Docker build attempt $attempt/3..."
|
||||
if docker build -t evershelf-test .; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $attempt failed — retrying in 20s..."
|
||||
sleep 20
|
||||
done
|
||||
echo "Docker build failed after 3 attempts"
|
||||
exit 1
|
||||
run: docker build -t evershelf-test .
|
||||
|
||||
- name: Test container starts
|
||||
run: |
|
||||
@@ -67,7 +53,7 @@ jobs:
|
||||
name: Validate Translation Files
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate JSON syntax
|
||||
run: |
|
||||
@@ -113,12 +99,10 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout (full history)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Always use the built-in GITHUB_TOKEN for checkout (read-only fetch).
|
||||
# WORKFLOW_PAT is only needed for the push step below.
|
||||
token: ${{ github.token }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure git bot identity
|
||||
run: |
|
||||
@@ -127,15 +111,6 @@ jobs:
|
||||
|
||||
- name: Merge develop → main
|
||||
run: |
|
||||
# ── ROOT CAUSE FIX ──────────────────────────────────────────────────
|
||||
# actions/checkout writes an http.extraheader (AUTHORIZATION: basic …)
|
||||
# that silently overrides any credentials embedded in git remote URLs.
|
||||
# We must clear it BEFORE setting the remote URL with WORKFLOW_PAT,
|
||||
# otherwise GITHUB_TOKEN is always used for the push and workflow-file
|
||||
# changes are rejected.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
git config --local --unset-all http."https://github.com/".extraheader 2>/dev/null || true
|
||||
|
||||
LAST=$(git log --oneline -1 origin/develop)
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
@@ -143,84 +118,4 @@ jobs:
|
||||
-m "chore: auto-merge develop → main
|
||||
|
||||
Triggered by: $LAST"
|
||||
|
||||
# ── PUSH STRATEGY ───────────────────────────────────────────────────
|
||||
# Priority 1: WORKFLOW_PAT (classic PAT, repo+workflow scopes)
|
||||
# → can push workflow file changes; set as a repo secret.
|
||||
# Priority 2: GITHUB_TOKEN fallback
|
||||
# → cannot push workflow files; strip them from the merge commit.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
PUSH_TOKEN="${{ secrets.WORKFLOW_PAT }}"
|
||||
if [ -z "$PUSH_TOKEN" ]; then
|
||||
WF=$(git diff --name-only origin/main -- .github/workflows/ 2>/dev/null || echo "")
|
||||
if [ -n "$WF" ]; then
|
||||
echo "::warning::WORKFLOW_PAT not set — stripping workflow changes from merge commit:"
|
||||
echo "$WF"
|
||||
git checkout origin/main -- .github/workflows/
|
||||
git diff --cached --quiet || git commit --amend --no-edit
|
||||
fi
|
||||
PUSH_TOKEN="${{ github.token }}"
|
||||
fi
|
||||
|
||||
git remote set-url origin "https://x-access-token:${PUSH_TOKEN}@github.com/${{ github.repository }}.git"
|
||||
git push origin main
|
||||
|
||||
# ── Auto-create GitHub Release on main ───────────────────────────────────
|
||||
# Runs after auto-merge succeeds. Reads version from index.html,
|
||||
# creates a release tag vX.Y.Z if it doesn't exist yet.
|
||||
# This powers the in-app update badge for self-hosted users.
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
needs: [auto-merge-to-main]
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from index.html
|
||||
id: version
|
||||
run: |
|
||||
VER=$(grep -oP 'header-version">v\K[\d.]+' index.html | head -1)
|
||||
echo "version=v${VER}" >> $GITHUB_OUTPUT
|
||||
echo "Detected version: v${VER}"
|
||||
|
||||
- name: Check if tag already exists
|
||||
id: tag_check
|
||||
run: |
|
||||
if git ls-remote --tags origin "refs/tags/${{ steps.version.outputs.version }}" | grep -q .; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Read CHANGELOG entry for this version
|
||||
id: changelog
|
||||
if: steps.tag_check.outputs.exists == 'false'
|
||||
run: |
|
||||
VER="${{ steps.version.outputs.version }}"
|
||||
# Extract the section for this version from CHANGELOG.md
|
||||
BODY=$(awk "/^## \[?${VER#v}\]?|^## ${VER}/,/^## [0-9]/" CHANGELOG.md | head -50 | tail -n +1 | grep -v "^## [0-9]" || true)
|
||||
if [ -z "$BODY" ]; then
|
||||
BODY="See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details."
|
||||
fi
|
||||
# Multiline output
|
||||
echo "body<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$BODY" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create release
|
||||
if: steps.tag_check.outputs.exists == 'false'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
name: "EverShelf ${{ steps.version.outputs.version }}"
|
||||
body: ${{ steps.changelog.outputs.body }}
|
||||
target_commitish: main
|
||||
make_latest: true
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
name: Security Scan (Trivy)
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
@@ -10,7 +7,6 @@ on:
|
||||
- 'Dockerfile'
|
||||
- 'docker-compose.yml'
|
||||
- 'api/**'
|
||||
- '.github/workflows/security.yml'
|
||||
schedule:
|
||||
# Run weekly on Monday at 07:00 UTC
|
||||
- cron: '0 7 * * 1'
|
||||
@@ -25,23 +21,22 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t evershelf:scan .
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: 'evershelf:scan'
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
ignore-unfixed: true
|
||||
exit-code: '0' # don't fail the build, just report
|
||||
|
||||
- name: Upload Trivy SARIF to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
category: 'trivy-docker'
|
||||
@@ -54,21 +49,20 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy filesystem scanner
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
format: 'sarif'
|
||||
output: 'trivy-fs-results.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
ignore-unfixed: true
|
||||
exit-code: '0'
|
||||
|
||||
- name: Upload Trivy FS SARIF
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: 'trivy-fs-results.sarif'
|
||||
category: 'trivy-fs'
|
||||
|
||||
@@ -49,7 +49,3 @@ evershelf-kiosk/local.properties
|
||||
data/error_reports.log
|
||||
data/latest_release_cache.json
|
||||
data/food_facts_cache.json
|
||||
data/category_ai_cache.json
|
||||
assets/img/logo/*_backup.*
|
||||
logs/*.log
|
||||
assets/vendor/transformers/Xenova/
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
RewriteEngine On
|
||||
|
||||
# Block sensitive files (Apache 2.4+)
|
||||
<Files ".env">
|
||||
Require all denied
|
||||
</Files>
|
||||
<Files ".env.example">
|
||||
Require all denied
|
||||
</Files>
|
||||
<Files "backup.sh">
|
||||
Require all denied
|
||||
</Files>
|
||||
<FilesMatch "^\.">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
# Force HTTPS (skip when terminated TLS is forwarded — Traefik, Caddy, NPM, …)
|
||||
#RewriteCond %{HTTPS} !=on
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} !^https$ [NC]
|
||||
#RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
# Force HTTPS
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
# API routing
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
|
||||
@@ -5,333 +5,6 @@ All notable changes to EverShelf will be documented in this file.
|
||||
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).
|
||||
|
||||
## [Unreleased] — Ideas & Roadmap
|
||||
|
||||
> Ideas collected during development. No priority or date implied.
|
||||
|
||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||
|
||||
## [1.7.42] - 2026-06-11
|
||||
|
||||
### Added
|
||||
- **Waste reason picker** — Discarding a product prompts for why (expired, spoiled, wrong storage, kept too long, bought too much, forgotten, bad quality, other) in IT/EN/DE/FR/ES.
|
||||
- **Waste learning** — Reasons are stored per product in `app_settings.waste_learning`; caps smart-shopping suggested quantities, surfaces preferred storage location, and tightens expiry alerts after repeated spoilage.
|
||||
- **`scripts/github-issue-triage.php`** — Reopens wrongly closed feature backlog items; closes resolved auto-report bugs with English comments.
|
||||
|
||||
### Fixed
|
||||
- **Inflated shopping total** — Price each Bring!/shopping line as **one retail purchase**; convert AI €/kg prices to estimated piece weight (200 g default) instead of multiplying by piece count; cap smart-shopping conf/pz suggestions used for pricing context.
|
||||
- **SQLite database locked (#201–#202)** — `inventory_use` and `shopping_add` (including Bring mode) wrapped in `dbWithRetry()`.
|
||||
- **Smart shopping timeout (#203–#204)** — `set_time_limit(120)` on `smartShopping()` / `smartShoppingCached()` for large inventories.
|
||||
- **Android kiosk CI** — Escaped apostrophes in locale `strings.xml` (de/es/fr/it); fixed Kotlin JSON string escaping in `SetupActivity.finishSetup()`.
|
||||
- **GitHub triage** — `triage-open-issues.php` no longer bulk-closes enhancement/feature backlog; reopened #98 (pin products) and #125 (cooking voice commands) where not yet implemented.
|
||||
|
||||
## [1.7.41] - 2026-06-08
|
||||
|
||||
### Fixed
|
||||
- **Docker/Traefik “Impossibile contattare il server”** — PHP 8.2 deprecation notices (`LoggingPDO::prepare`) were emitted as HTML before JSON, breaking `fetch().json()` on the startup health check; API bootstrap now suppresses HTML error output in production.
|
||||
- **Traefik HTTPS redirect loop** — `.htaccess` skips the HTTPS redirect when `X-Forwarded-Proto: https` is already set (compatible with Traefik `sslheader` middleware); no need to disable `.htaccess` manually.
|
||||
- **LoggingPDO PHP 8.2** — `#[\ReturnTypeWillChange]` on `prepare()` to eliminate deprecation noise in error logs.
|
||||
|
||||
## [1.7.40] - 2026-06-08
|
||||
|
||||
### Added
|
||||
- **Qty unit badges** — Quantity inputs show the active unit (g, ml, conf, pz, …) on use, add, recipe-use, edit and throw modals; scale live label “Inserimento in …”.
|
||||
- **Recipe shopping suggestions** — AI recipes can list optional missing ingredients with one-tap add to Bring!/shopping list.
|
||||
- **Recipe frozen badge** — Freezer items flagged in pantry lines and recipe UI; prompt rule for cooking from frozen.
|
||||
- **Health check `db_writable`** — Startup diagnostic detects non-writable SQLite file (common Docker volume issue).
|
||||
- **`scripts/triage-open-issues.php`** — Maintenance helper to comment/close GitHub issues via encrypted token.
|
||||
- **Ops CLI scripts** — `audit-finished-shopping.php`, `backfill-finished-shopping.php`, `sync-shopping-bring.php`, `install-transformers-model.sh` (offline Xenova classifier bootstrap).
|
||||
|
||||
### Fixed
|
||||
- **SQLite database locked** — `PRAGMA busy_timeout` 10s + `dbWithRetry()` on `inventory_update` under cron/PWA contention.
|
||||
- **Barcode duplicate on save** — `saveProduct` merges or returns 409 instead of HTTP 500 on UNIQUE barcode.
|
||||
- **EverLog CLI crash** — Safe cast of `REQUEST_METHOD` when null (kiosk/cron).
|
||||
- **Spesa scan crash** — `currentPage` → `_currentPageId` in `_applySpesaScanUI`.
|
||||
- **Recipe quantities** — Piece products use 1 pc base; serving caps for onions, leafy greens, minestrone; pantry-only post-processing; conf/g display fixes.
|
||||
- **Smart shopping purchased block** — Server-side blocklist + spesa mode sync prevents cron from re-adding bought items.
|
||||
|
||||
### Changed
|
||||
- **Docker behind Traefik** — Apache `SetEnvIf X-Forwarded-Proto https HTTPS=on` to avoid redirect loops.
|
||||
|
||||
## [1.7.39] - 2026-06-06
|
||||
|
||||
### Added
|
||||
- **`resolve_barcode` API** — Single round-trip: local catalog lookup plus **parallel** external search (Open Food Facts IT/world, UPC Item DB, Open Products Facts, Open Beauty Facts via `curl_multi`). Results are stored in SQLite `barcode_cache` for instant repeat scans.
|
||||
- **Spesa barcode fast path** — In shopping mode, a successful scan opens the **add form directly** (skips the intermediate action page).
|
||||
- **Session barcode cache** — In-memory cache avoids duplicate API calls when scanning many items in one trip.
|
||||
- **Manual expiry flag (`expiry_user_set`)** — User-entered expiry dates are kept when changing location, vacuum seal, or moving stock; only auto-estimated dates are recalculated.
|
||||
- **Family sibling 24h dedup** — After confirming “Sì, tutto ok” on a similar in-stock product, the check prompt is suppressed for the same `shopping_name` family for 24 hours (synced via `family_sibling_confirmed` in app settings).
|
||||
- **Family sibling stock line** — Spesa prompt shows readable stock (e.g. `4 conf (da 20g)`); new `family_sibling_check` / `family_sibling_stock` strings in IT/EN/DE/FR/ES.
|
||||
- **Quick-edit product notes** — Notes field in the inline name/brand editor on the product action page.
|
||||
|
||||
### Fixed
|
||||
- **Kiosk / WebView stability** — Guard `$_SERVER['REQUEST_METHOD']` when null; fix JS temporal-dead-zone crashes (`setProgress`, `enriched` → `enrichedRaw`, `duplicateNames`); lazy-load ZBar WASM so kiosk startup no longer OOM-crashes.
|
||||
- **Empty barcode SQL error** — Multiple products with `barcode = ''` violated SQLite UNIQUE; empty strings are normalized to `NULL` (migration included).
|
||||
- **Spesa ghost products** — Finished/catalog AI candidates and scan recents no longer show zero-stock items in shopping mode; `family_sibling_suggest` requires live inventory quantity.
|
||||
- **Insalata di riso misclassification** — Prepared rice salads (e.g. Ponti) map to `pasta` instead of fresh `verdura`; server and client rules aligned.
|
||||
- **Family sibling prompt readability** — Quantity and question text use high-contrast colours on the dark overlay.
|
||||
- **Move after use / recipe move** — Respects manually set expiry (`expiry_user_set`); purchased items marked on blocklist after spesa add.
|
||||
|
||||
### Changed
|
||||
- **Barcode lookup** — Replaced sequential API waterfall (up to ~15s) with parallel fetch (~1–2s first hit); 30-minute negative cache for unknown codes.
|
||||
- **Local barcode search** — Automatically tries EAN-13 / UPC-A variant barcodes.
|
||||
|
||||
## [1.7.38] - 2026-06-04
|
||||
|
||||
### Fixed
|
||||
- **Finished products on shopping list** — Depleted items are now added to Bring! under their generic `shopping_name` (e.g. “Affettato”). If the generic is already on the list, the specific variant is appended to the specification instead of being skipped. Confirming a ghost/finished product from the dashboard banner also triggers this flow.
|
||||
- **Unstable shopping total** — Dashboard, Spesa tab, Home Assistant and screensaver now share one **weekly canonical total** (`PRICE_UPDATE_WEEKS=1`). Totals use **1 package per list item** (no more day-to-day swings from smart-shopping suggested quantities). AI prices are fetched only for items missing from cache; manual 🔄 refresh forces an update.
|
||||
- **Screensaver price mismatch** — Screensaver waits for the canonical total sync before displaying the amount, matching the other surfaces.
|
||||
|
||||
### Changed
|
||||
- **Shopping list UI** — Generic list entries show the group name with specific finished variants underneath (same pattern as smart shopping suggestions).
|
||||
|
||||
## [1.7.37] - 2026-06-04
|
||||
|
||||
### Fixed
|
||||
- **Recipe pantry false positives** — Generated recipes no longer mark ingredients as ✅ in pantry when the product is not in stock or the name does not strictly match an inventory item (score ≥ 80, no generic alias expansion like *formaggio* → any cheese). AI prompt now receives the full in-stock list and explicit rules forbidding invented ingredient names.
|
||||
- **`renderRecipe` crash** — Restored missing `qtyNum` variable when reopening archived recipes with pantry ingredients (ReferenceError on the "Use ingredient" button).
|
||||
|
||||
### Changed
|
||||
- **`re-enrich-recipe.php`** — Re-applies strict pantry matching before stock hints when fixing archived recipes.
|
||||
|
||||
## [1.7.36] - 2026-06-04
|
||||
|
||||
### Added
|
||||
- **Recipe ingredient stock hints** — Pantry ingredients in generated and archived recipes now show a small line under each item: how much you have in stock and how much would remain after use. Quantities are summed across all storage locations.
|
||||
- **Zero-waste use-all rule** — When the leftover would be less than **5% of the full sealed package** (or **10%** when less than one full unit is left on an opened pack), the recipe quantity is automatically bumped to use everything on hand (♻️ badge + note in all 5 languages).
|
||||
- **Ghost product detection** — Dashboard anomaly banner now surfaces products that vanished from inventory (ledger says stock should exist but no rows remain), with a restore prompt and quantity input.
|
||||
- **`inventory_restore_ghost` API** — Restores a vanished product row from the banner without losing transaction history.
|
||||
- **`product_merge` API** — Merges duplicate product records (inventory, transactions, aliases) into a single canonical product.
|
||||
- **Maintenance scripts** — `scripts/sync-i18n.py` (5-language key sync), `scripts/re-enrich-recipe.php` (re-apply stock hints to archived recipes), `scripts/merge-duplicate-products.php` (batch duplicate merge).
|
||||
|
||||
### Fixed
|
||||
- **Unified shopping total** — Dashboard, Spesa page and screensaver now share one canonical server-side total (`shopping_total_cache`); background refresh runs during screensaver too.
|
||||
- **Recipe stream auth** — `generate_recipe_stream` and other direct `fetch()` calls now send the API token consistently, fixing 401 errors during recipe generation.
|
||||
- **Home Assistant auth compatibility** — HA integration endpoints accept the configured API token without breaking legacy setups.
|
||||
- **Security hardening** — API bootstrap modularised; scale SSE relay and sensitive routes require auth; env migration script for legacy installs.
|
||||
- **Dashboard banner i18n** — Fixed raw translation keys (`dashboard.banner_*`) showing in the UI; full sync across IT/EN/DE/FR/ES with cache bust.
|
||||
- **Ghost banner permanently hidden** — Removed incorrect `fin_*` hide logic that suppressed vanished-product alerts after a false "finished" confirmation.
|
||||
- **`deleteInventory` / `use_all` dedup** — Inventory deletions now log transactions; duplicate `use_all` within 60 s is deduplicated; `confirmFinished` reconciles ledger mismatches.
|
||||
- **Duplicate product prevention** — `saveProduct` blocks creating a second product with the same normalised name.
|
||||
- **Recipe qty normalization** — conf+weight ingredients (e.g. ceci, basilico) now keep recipe amounts in grams/ml instead of copying the inventory conf count; use-all percentage is calculated on the sealed package size, not current stock.
|
||||
|
||||
## [1.7.35] - 2026-06-02
|
||||
|
||||
### Fixed
|
||||
- **Barcode scanner accepts invalid codes** — Manual barcode input with an incorrect EAN checksum now blocks the lookup and shows an error (previously showed a warning but proceeded anyway). The native `BarcodeDetector` path now also validates EAN-8/EAN-13/UPC checksum before confirming a scan, consistent with the Quagga fallback which already did this check.
|
||||
- **Recipe persons +/− buttons stopped working in the generation dialog** — A duplicate `adjustRecipePersons` function added for the post-generation rescaler was overriding the one that updated the persons input in the recipe setup dialog. The rescaler is now named `scaleRecipePersons` to avoid the conflict.
|
||||
|
||||
## [1.7.34] - 2026-05-30
|
||||
|
||||
### Added
|
||||
- **AI visual barcode fallback** — When the barcode scanner fails to read a barcode within 5 seconds, EverShelf can now automatically capture a camera frame and send it to Gemini Vision to visually identify the product (name, brand, category). On success the product is saved and the inventory form opens just as if a barcode had been scanned. A new toggle in **Settings → Camera** (`AI visual identification (5s fallback)`) lets users enable or disable this feature at any time. Requires Gemini API key configured. Disabled by default.
|
||||
|
||||
## [1.7.33] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
- **HA sensor `shopping_total` always null** — `haInventorySensor` was reading `shopping_total_cache.json` with a 1-hour TTL (cache populated only by the JS frontend, so it was often empty). Extended TTL to 24 hours and added an inline fallback: when the cache is absent or stale, the sensor now computes the total directly from `shopping_price_cache.json` without any AI calls. Queries `shopping_list` joined to `products` for the canonical `shopping_name`, then looks up both v3 and legacy v0 cache key formats to maximise hit rate. Works in both internal and Bring shopping modes.
|
||||
- **HA `ha_refresh_prices` using non-existent columns** — `haInventorySensor` and `haRefreshPrices` were querying `quantity`, `unit`, `checked` from `shopping_list` — columns that do not exist in that table (schema: `id, name, raw_name, specification, added_at, sort_order`). Changed to `SELECT name` with `shopping_name` join and default `qty=1 / unit=pz`.
|
||||
|
||||
|
||||
## [1.7.32] - 2026-05-29
|
||||
|
||||
### Changed
|
||||
- **Smarter expiry u2192 shopping list logic** — The "expiring soon" threshold is now 7 days (was 3), giving enough time to plan the next shopping trip. Items expiring soon are only flagged for restocking when the user is a **regular buyer** (`isRegular`) and either stock is low (<50%) or the consumption rate predicts the item will expire before being used. Non-regular products keep the old 3-day safety-net. Expired items are now only added to the shopping list when `isRegular || buyCount >= 2` — products that expired unused without ever being a staple no longer pollute the list; the expiry banner handles them.
|
||||
|
||||
|
||||
## [1.7.31] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
- **New pack merges into opened pack on add** — `addToInventory` was looking for ANY existing row for the same product+location and adding the new quantity to it. This caused a newly purchased sealed pack to be silently merged with an already-opened pack, collapsing two physically distinct containers into one row and corrupting the `opened_at` timestamp. The fix now searches only for a **sealed** (unopened) row (`opened_at IS NULL`) to merge into. If only opened rows exist, a new sealed row is created instead — keeping the two packs separate and allowing the anomaly model and shelf-life tracker to work correctly.
|
||||
|
||||
|
||||
## [1.7.30] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
- **False consumption anomaly with multi-row stock** — The anomaly detection banner was evaluating each inventory row in isolation. Products split across multiple rows (e.g. one opened pack with 1 pz + one sealed pack with 6 pz) incorrectly triggered a "consumed faster than expected" warning because only the opened row (1 pz) was compared against the model. The check now aggregates the total quantity across all rows for the same product before deciding to flag an anomaly. If the combined total ≥ expected remaining, the anomaly is suppressed.
|
||||
|
||||
|
||||
## [1.7.29] - 2026-05-29
|
||||
|
||||
### Added
|
||||
- **Buy-cycle consumption prediction** — Products that are never tracked per-use (salt, spices, cleaning supplies, etc.) now use the average time between restocks as a proxy for consumption rate. When a product has ≥ 3 purchase events and no individual `out` events, EverShelf calculates the average buy cycle (`(lastBuy - firstBuy) / (buyCount - 1)`) and estimates how many days of stock remain in the current cycle. The product appears in the smart shopping list with a reason like "Finisce tra ~12gg (ciclo medio 75gg)" before it runs out, rather than only after. These products are now also treated as `isRegular` so all stock-level urgency checks apply correctly.
|
||||
|
||||
|
||||
## [1.7.28] - 2026-05-30
|
||||
|
||||
### Fixed
|
||||
- **Duplicate auto-reported issues** — The GitHub issue reporter was relying solely on the GitHub Search API for deduplication. Because search indexing has a several-minutes lag, rapid error recurrences each created a new issue before the previous one was indexed, producing ~50 duplicate issues. The reporter now uses a local file cache (`data/reported_issue_fps.json`, with `/tmp/` fallback when `data/` is not writable) as the primary deduplication store. A 30-minute per-fingerprint comment throttle is also applied to prevent flooding an existing issue. GitHub Search is used only on first run or after a cache miss. Closes [#134](https://github.com/dadaloop82/EverShelf/issues/134) (and all duplicates #135–#183).
|
||||
|
||||
## [1.7.27] - 2026-05-29
|
||||
|
||||
### Added
|
||||
- **HA sensor enrichment** — All HA sensor attributes that list products now include full product details: `location`, `brand`, `category`, `days_remaining`, `opened_at`, `vacuum_sealed`, `default_quantity`, `package_unit`, `product_id`, `inventory_id`. Applies to `expiring_list`, the new `expired_list`, and the new `low_stock_list`.
|
||||
- **HA `expired_list` attribute** — `sensor.evershelf_overview` now exposes `expired_list` (full details for all expired items, not just a count).
|
||||
- **HA `low_stock_list` attribute** — New attribute listing all items with quantity ≤ 1 with full product info.
|
||||
- **HA `sensor=product` endpoint** — New `GET /api/?action=ha_sensor&sensor=product` returns the full inventory with all product details. Optional filters: `&id=N`, `&name=...`, `&location=...`.
|
||||
- **Inventory edit safety guard** — Confirm dialog when saving a quantity that is unusually large for its unit (e.g. 183 conf), preventing accidental data loss from unit-confusion typos.
|
||||
- **Bread shelf-life in fridge** — Opened shelf-life rules added for piadina/crescia (2 days), packaged sliced bread/bauletto (4 days), and generic bread (3 days).
|
||||
|
||||
### Fixed
|
||||
- **Recipe AI ingredient substitution** — Added explicit rule to both recipe prompts preventing Gemini from substituting ingredient forms (e.g. fresh tomatoes ↔ passata, fresh milk ↔ UHT ↔ cream, flour 00 ↔ wholemeal).
|
||||
- **HA cron webhook payload** — Expiry alert webhook items now include full product details (brand, category, location, days_remaining, opened_at, vacuum_sealed) instead of only name/qty/unit/expiry_date.
|
||||
|
||||
### Docs
|
||||
- `docs/wiki/Home-Assistant.md` — Documented new `sensor=product` endpoint, full product schema table, enriched webhook payload example, and Lovelace/automation template examples using `location` and `days_remaining`.
|
||||
|
||||
## [1.7.26] - 2026-05-26
|
||||
|
||||
### Added
|
||||
- **Monthly stats panel** — Third rotating card in the insight banner (anti-waste → nutrition → monthly stats, 1 minute each). Shows products consumed this month with a trend vs. the previous calendar month (↑/↓/→ with % delta), animated horizontal category bars, and badges for items added, wasted, and top-used product. Falls back gracefully when the current month has no transactions. Closes [#100](https://github.com/dadaloop82/EverShelf/issues/100).
|
||||
- **Extended smart-shopping horizon for staples** — Items consumed ≥ 4 times/month now get a 28-day look-ahead window; ≥ 2 times/month get 21 days. Frequently used staples no longer disappear from the smart list between restocks. Closes [#98](https://github.com/dadaloop82/EverShelf/issues/98).
|
||||
|
||||
### Fixed
|
||||
- **TTS test interactive confirmation** — Test timeout raised from 4 s to 10 s; instead of an error, the UI shows a YES/NO prompt ("Did you hear it?") so users can confirm or report failure explicitly.
|
||||
- **`end()` PHP 8 reference error** — `_offFetchProduct()` passed the result of `??` directly to `end()`, which requires a variable. Fixed with a temporary variable.
|
||||
- **Database migration crash on fresh installs** — `migrateDB()` tried to rename the `transactions` table before it existed. A `sqlite_master` guard now calls `initializeDB()` and returns early when the schema is absent. Closes [#131](https://github.com/dadaloop82/EverShelf/issues/131), [#133](https://github.com/dadaloop82/EverShelf/issues/133).
|
||||
- **Health-check crash on empty database** — `db_row_count` query was executed even when the `inventory` table was missing, causing a fatal PDO error. The query is now skipped until the schema is fully initialised. Closes [#132](https://github.com/dadaloop82/EverShelf/issues/132).
|
||||
- **Insight banner stuck on one panel** — Rotation interval was 1 hour (effectively invisible); now 60 seconds. `_applyInsightPhase` also now skips empty panels instead of always falling back to the anti-waste card, so the rotation works correctly even when a panel has no data.
|
||||
- **Untranslated OpenFoodFacts category labels** — Categories stored as OFF slugs (`en:plant-based-foods-and-beverages`, `en:dairies`, …) were shown raw. A new `_normalizeCat()` PHP function maps ~60 OFF slugs to Italian app categories; counts are re-aggregated after normalisation so `en:dairies` + `en:milk` both contribute to `latticini`.
|
||||
|
||||
## [1.7.25] - 2026-05-25
|
||||
|
||||
### Added
|
||||
- **Home Assistant integration** — Full bidirectional HA support: inventory sensor (`sensor.evershelf_*`) exposes item counts, expiring items, shopping total, opened items and next-expiry info. Webhooks fire on inventory changes (add/use/shopping). Daily cron alert notifies via HA for items expiring within the configured threshold. TTS announces cooking steps through HA Media Player. New Settings tab 🏠 with connection test, TTS preset (Piper, Google, Nabu Casa), webhook config, and YAML snippet for `configuration.yaml`. Resolves [#111](https://github.com/dadaloop82/EverShelf/issues/111).
|
||||
- **Offline mode** — Full offline-first support. Full-screen overlay on network loss; "Continue offline" button after 3 s, auto-enter after 8 s. Inventory and settings are synced to `localStorage` at startup and cached on every successful API call. Writes (add/use/update/delete) are queued and synced on reconnect with optimistic UI updates. Pending operations survive page refresh and are re-synced automatically at next startup. AI/network-dependent sections (anti-waste chart, nutrition analysis, recipe generator, price fetching, Gemini chat) are hidden in offline mode. `remoteLog` and `reportError` are buffered offline and flushed on restore. Broken external images replaced with a grey placeholder.
|
||||
- **Offline-computed dashboard** — While offline, `inventory_summary` and `stats` (expiring/expired/opened) are derived client-side from the local cache so all dashboard stat cards and expiry alerts show accurate data.
|
||||
|
||||
### Fixed
|
||||
- **Offline banner flood** — Opened items in the offline `stats` response lacked `is_edible`; `!undefined` evaluated to `true`, causing every opened item to be shown as "not edible" in the dashboard banner. Field is now set to `true` (client-side shelf-life check already handles genuinely expired items).
|
||||
- **Version update badge showing older versions** — `_checkWebappUpdate` used `latestTag !== _loadedVersion` (inequality only), so running a newer dev build triggered an "update available" badge for an older GitHub release. Now uses `_semverGt(latest, current)` so only genuinely newer releases trigger the badge.
|
||||
- **Bring! items re-appearing after manual purchase removal** — `removeBringItem` and `confirmShoppingItemFound` now call `_markBringPurchased` immediately, and `autoAddCriticalItems` respects the blocklist for depleted items.
|
||||
- **Barcode lookup false "not found"** — New `_offFetchProduct()` tries three barcode candidates (given, UPC-A↔EAN-13 conversion) across two Open Food Facts locales with auto-retry.
|
||||
- **Partial throw from expired-items banner** — "Butta" now opens the throw modal (qty + location) instead of silently deleting the entire inventory row.
|
||||
- **Related stock display when scanning branded products** — When scanning a product, the action page now shows a green card listing any inventory items from the same generic family already at home.
|
||||
|
||||
## [1.7.24] - 2026-05-21
|
||||
|
||||
### Fixed
|
||||
- **Dark mode resets to Auto on every reload** — `dark_mode` was never saved to `.env` (missing from `saveSettings` and `getServerSettings`). It is now fully server-side like all other settings; `localStorage` retains only a pre-render hint for the flash-prevention IIFE.
|
||||
- **Cooking timer — no sound or speech on Android kiosk** — Three independent root causes fixed: (1) `AudioContext` was created fresh outside a user gesture, starting in `suspended` state and failing silently; a shared pre-unlocked context (`_sharedAudioCtx`) is now created during user gestures (`startCookingMode`, `addCookingTimer`). (2) The `_cookingTTS` gate (for step narration) was incorrectly blocking timer alarm speech — timer alerts now always speak regardless of that flag. (3) `_kioskBridge.speak()` (native Android TTS) was never considered as a fallback when `window.speechSynthesis` is absent in the WebView.
|
||||
- **Scale use ignored for conf products** — `_scaleAutoFillUse()` returned early when `_activeUnit !== 'sub'`, but conf products default to `conf` mode. The function now auto-switches to sub mode before processing the weight reading. Scale button (`btnUse`) is also now visible for conf products that have a g/ml package unit.
|
||||
- **Kiosk — native settings button reappearing unexpectedly** — `closeModal()` was calling `setNativeSettingsVisible(true)`, restoring the native Android settings button after every modal close. `_injectKioskOverlay()` now permanently hides the native button; scattered per-modal show/hide calls removed; a ⚙️ web button opens the in-app settings page.
|
||||
- **SQLite database locked during inventory update** — `updateInventory()` made 3–4 separate write statements without a transaction; a concurrent cron job could acquire the write lock between them, causing a `database is locked` PDO error. All writes are now wrapped in `beginTransaction()`/`commit()`, with the Bring! HTTP sync deferred to after `commit()`. Closes [#109](https://github.com/dadaloop82/EverShelf/issues/109), [#110](https://github.com/dadaloop82/EverShelf/issues/110).
|
||||
- **Depleted-item urgency incorrect** — Items with zero quantity were assigned urgency based on recency of use rather than consumption frequency. Urgency is now computed from `usesPerMonth` only, so frequently-used depleted items are correctly flagged as urgent.
|
||||
- **0.5 conf use and decimal display** — Default mode on the use-quantity page is now conf for conf products; fraction buttons (½, ¼, ¾) work correctly; conf decimals are shown in the transaction history log.
|
||||
- **Bring! health check token warning** — Token validity warning was shown even for valid tokens; health check is now restored with correct token-format detection.
|
||||
- **Recipe quantities for conf+weight products** — Quantities are now calculated correctly when a conf product has a gram-based package unit.
|
||||
- **Shopping settings not syncing across clients** — `shopping_*` keys were missing from `serverKeys` in `_applySyncedSettings`; shopping settings were client-local. All shopping keys now sync from server on load.
|
||||
|
||||
### Added
|
||||
- **Native shopping list** — Built-in shopping list (no Bring! required) as an alternative mode (`SHOPPING_MODE=internal`). Resolves [#105](https://github.com/dadaloop82/EverShelf/issues/105).
|
||||
- **Google Drive backup via localhost OAuth** — GDrive backup no longer requires a public domain; the OAuth redirect flow uses `http://localhost` via a temporary local server, compatible with self-hosted setups. Resolves [#107](https://github.com/dadaloop82/EverShelf/issues/107).
|
||||
|
||||
### Changed
|
||||
- **All settings fully server-centralised** — Removed remaining `localStorage` usage for user preferences; all settings are now read from and written to `.env` via the API. Preferences are shared across all devices (desktop, phone, kiosk) automatically.
|
||||
|
||||
## [1.7.23] - 2026-05-18
|
||||
|
||||
### Added
|
||||
- **⚙️ Generali tab** — new first tab in Settings groups all global settings: language, currency, theme, screensaver, zero-waste tips, inventory export. Old Language tab removed.
|
||||
- **DB auto-cleanup** — `RECIPE_RETENTION_DAYS` (default 7) and `TRANSACTION_RETENTION_DAYS` (default 7) added to `.env`; old rows are deleted automatically every cron cycle, followed by `VACUUM` to compact the database. Manual trigger: `GET /api/?action=db_cleanup`.
|
||||
- **Vacuum-sealed expiry grace period** — `VACUUM_EXPIRY_EXTENSION_DAYS` (default 30): vacuum-sealed products are only flagged as expired N days *after* the printed date, preventing false alarms on long-lasting items like cured meats.
|
||||
- **Gemini AI usage tracking** — monthly and yearly token/cost stats now shown in Settings → ℹ️ Info tab, using tracked data from `data/ai_usage.json`. Cost rates configurable via `GEMINI_COST_25F_IN/OUT` and `GEMINI_COST_20F_IN/OUT` in `.env`.
|
||||
|
||||
### Changed
|
||||
- **Auto theme is now time-based** — "Automatico" mode switches to dark at 20:00 and back to light at 07:00, based on server/device clock (not OS preference). Re-evaluates every 5 minutes; ideal for always-on kiosk displays.
|
||||
- **`dispensa.db` auto-deleted** — if the legacy empty `dispensa.db` file appears alongside `evershelf.db`, it is now removed automatically by the health check.
|
||||
- **ZeroWaste tips and screensaver timeout** — these settings were not being persisted to `.env` on save (missing from POST payload); fixed.
|
||||
|
||||
## [1.7.22] - 2026-05-17
|
||||
|
||||
### Fixed
|
||||
- **DB name corrected** — `health_check` now looks for `evershelf.db` (was wrongly looking for `dispensa.db`). Auto-migration included: if `evershelf.db` is missing but `dispensa.db` exists, it is renamed automatically on startup.
|
||||
- **Removed legacy `data/dispensa.db`** — the old database file has been deleted; only `evershelf.db` is used.
|
||||
- **Conditional checks** — Bring!, TTS, Scale and Internet checks only run when the respective feature is enabled in `.env` (no more false ❌/⚠️ for unconfigured features).
|
||||
- **Backups check** — no longer checks if `data/backups/` is writable by www-data (cron writes as root). Now checks that backup files actually exist and the most recent one is recent.
|
||||
- **Bring! token check** — reads `data/bring_token.json` file instead of looking for a non-existent `BRING_ACCESS_TOKEN` env var.
|
||||
|
||||
### Changed
|
||||
- **Warning popup with 5s countdown** — when non-critical checks fail at startup, a styled popup appears showing each warning with its label and a plain-language hint explaining the problem. A countdown bar auto-closes the popup after 5 seconds, then the app starts normally.
|
||||
- **Error blocking popup** — when critical checks fail, a clear blocking panel shows with title "Errore critico", each failed check listed with its explanation hint, and a Retry button. The app does not start.
|
||||
- **`db_legacy` check added** — warns (optional) if the old `dispensa.db` file is still present alongside `evershelf.db`.
|
||||
- **32 total checks** — added `db_legacy`, `tts_url`, `scale_gateway` to the check set (conditional).
|
||||
- **Hint messages** — every check now has an Italian-language `hint` field explaining what is wrong and how to fix it.
|
||||
|
||||
## [1.7.21] - 2026-05-20
|
||||
|
||||
### Changed
|
||||
- **Startup health check** — Complete redesign from a banner checklist to a **real-time progress bar**. The bar fills smoothly as each of 29 diagnostic checks runs, with the current check name shown below in real time. Warnings (⚠️) are displayed as amber badges that remain visible for 2 seconds before the app proceeds. Critical failures turn the bar red and show a detailed error block with a Retry button.
|
||||
- **29 comprehensive checks**: PHP version, 8 PHP extensions (pdo_sqlite, curl, json, mbstring, openssl, fileinfo, zip, intl), PHP memory/timeout/upload config, data directory, rate_limits dir, backups dir, disk write test, free disk space, SQLite connection, required tables, integrity (PRAGMA quick_check), WAL mode, DB size, inventory row count, .env file, Gemini AI key, Bring! credentials, Bring! token, cURL SSL, internet reachability.
|
||||
- Warnings now clearly visible: each non-critical failure shows as a named amber badge (e.g. "⚠️ Bring! token") that cannot be missed.
|
||||
|
||||
## [1.7.20] - 2026-05-20
|
||||
|
||||
### Added
|
||||
- **Startup health check** — During the splash screen, the app now runs a comprehensive server-side diagnostic before loading: PHP version, required extensions (pdo_sqlite, curl, mbstring, json), `data/` directory writability, SQLite database connection and table integrity, `.env` file presence, Gemini AI key and Bring! token. Results are displayed as an animated checklist (✅ / ⚠️ / ❌). Critical failures (DB, extensions, data dir) block the app with a clear error message and a "Retry" button — the app never starts silently broken. Non-critical warnings (missing Gemini key, Bring! token) are shown as amber items but do not block startup.
|
||||
- New `?action=health_check` PHP endpoint (early-exit, no rate-limit, no auth).
|
||||
- New translation keys `startup.*` in all 5 languages (IT, EN, DE, FR, ES).
|
||||
|
||||
## [1.7.19] - 2026-05-19
|
||||
|
||||
### Added
|
||||
- **Zero-waste tips during cooking** — When cooking mode is active, a ♻️ card appears below each step that generates reusable scraps (peels, cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Gemini generates the tips as part of the recipe JSON at no extra API cost. Tips are dismissible per-step and reset on recipe restart. Opt-in toggle in Settings → Zero-waste tips (default OFF). Resolves [#76](https://github.com/dadaloop82/EverShelf/issues/76).
|
||||
- New translation keys `cooking.zerowaste_*` and `settings.zerowaste.*` in all 5 languages (IT, EN, DE, FR, ES).
|
||||
|
||||
## [1.7.18] - 2026-05-19
|
||||
|
||||
### Added
|
||||
- **Dark mode** — New theme selector in Settings (Appearance card): **Off (Light)**, **On (Dark)**, **Auto (follows system)**. Applied immediately on page load to prevent white flash. Resolves [#78](https://github.com/dadaloop82/EverShelf/issues/78).
|
||||
- **Export inventory** — New 📤 button in inventory page header opens a modal to download the inventory as **CSV** (UTF-8 with BOM, Excel-compatible) or open a **print-ready HTML page** (auto-triggers print dialog for PDF). Export card also available in Settings tab. Resolves [#64](https://github.com/dadaloop82/EverShelf/issues/64).
|
||||
- `translations/de.json`: fixed missing `log.recipe_prefix` key.
|
||||
|
||||
## [1.7.17] - 2026-05-19
|
||||
|
||||
### Added
|
||||
- **French translation (🇫🇷 Français)** — Complete `translations/fr.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77).
|
||||
- **Spanish translation (🇪🇸 Español)** — Complete `translations/es.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77).
|
||||
- Language selector in Settings now shows all 5 languages: 🇮🇹 Italiano, 🇬🇧 English, 🇩🇪 Deutsch, 🇫🇷 Français, 🇪🇸 Español.
|
||||
- Default fallback language changed from Italian to English (for users with unsupported browser locale).
|
||||
- Setup wizard "Done" screen and navigation buttons localised for French and Spanish.
|
||||
|
||||
## [1.7.16] - 2026-05-17
|
||||
|
||||
### Added
|
||||
- **Barcode scan history** — Last 20 scanned products are stored server-side (SQLite `app_settings`) and shown as chips in the scan page (`#scan-recents-chips`). Tapping a chip selects the product directly — no need to scan again. Resolves [#68](https://github.com/dadaloop82/EverShelf/issues/68).
|
||||
- **Full server-side user-data centralisation** — All user preferences previously siloed in `localStorage` per-device are now synced to the server via `app_settings_save` and loaded back at startup via `app_settings_get`. Affected data: shopping tags, pinned Bring! items, location preferences (use/move), auto-added Bring! entries, Bring! purchased blocklist, no-expiry dismissed products. Data is now shared across all devices (desktop, phone, kiosk, Android app).
|
||||
- **One-time localStorage migration** — On first load, any data found in the old localStorage keys (`shopping_tags`, `_userPinnedBring`, `_prefUseLoc`, `_prefMoveLoc`, `_autoAddedBring`, `_bringPurchasedBlocklist`, `_noExpiryDismissed`, `evershelf_scan_recents`) is automatically migrated to the server and the local keys are removed.
|
||||
|
||||
## [1.7.15] - 2026-05-16
|
||||
|
||||
### Added
|
||||
- **Full i18n audit** — Comprehensive sweep of all user-visible strings in `app.js` and `index.html`. 25+ new translation keys added across `it.json`, `en.json`, `de.json`, covering: vacuum toast, TTS voice controls, timer step labels, product note labels, error messages, expiry form, barcode hint, category select placeholder, cooking step fallback, `form.select_placeholder`, `btn.yes_short`/`no_short`, `add.vacuum_question`, `add.vacuum_saved`, `move.vacuum_seal_rest`, `cooking.step_fallback`, `error.prefix`/`unknown`, `product.select_variant`, and more.
|
||||
- **Splash screen redesign** — Logo displayed prominently, spinner below, app version shown at the bottom; version label injected dynamically at boot time so it never gets out of sync. Minimum 3-second display duration enforced: `_splashStart` is recorded before `DOMContentLoaded`; the fade-out is delayed by the remaining time if the app loads faster than 3 s.
|
||||
- **Demo GIF in README** — `assets/img/demo.gif` (processed at 2× speed, ~36 s) added to the `## 📸 Screenshots` section.
|
||||
- **`pz`/`conf` unit labels translated** — "pz" now shows as "pcs" in English and "Stk" in German; "conf" shows as "pkg" / "Pkg". All `unitLabels` objects in JS now use `t('units.pz')` / `t('units.conf')`.
|
||||
|
||||
### Fixed
|
||||
- **Camera button (📷) opened kiosk SettingsActivity on Android** — The native `btnSettings` ImageButton in the kiosk layout was positioned `top|end` with `alpha=0.12` (nearly invisible), sitting directly on top of the HTML scan button in the webapp header. Every tap on the 📷 button was intercepted by the native View and opened `SettingsActivity`. Fixed: moved `btnSettings` to `bottom|end` (above the bottom nav bar, `marginBottom=80dp`) and increased `alpha` to `0.28` so it is clearly separate from the header. Kiosk versionCode bumped to 16.
|
||||
- **Camera button (📷) opened settings on Android Chrome/Brave** — `pointerleave` fired before `pointerup` when finger drifted slightly, cancelling the long-press timer and leaving the browser to dispatch a synthetic `click` that bubbled to an unintended handler. Fixed: added `setPointerCapture` (prevents `pointerleave` during touch) and `preventDefault` (blocks synthetic click); replaced `pointerleave` with `pointercancel` handler. Added `touch-action: manipulation` to `.header-scan-btn` CSS.
|
||||
- **Logo white background on splash screen** — Re-processed both `logo.png` and `logo_icon.png` with fuzz 35% alpha extraction, removing the white background that was visible against the dark splash background (`#0f172a`).
|
||||
- **Recipe button label** — Shortened to "Ricetta" / "Recipe" / "Rezept" for compact display in the inventory quick-action modal.
|
||||
- **Quantity decimal precision** — `qtyNum` in recipe/cooking ingredient buttons and `conf` fallback display in inventory cards now limited to 1 decimal place (was showing 7+ decimal places from raw AI output, e.g. `0.25353223 conf`).
|
||||
- **"Errore" / "Error" fallback strings** — All remaining Italian hardcoded `'Errore'` fallbacks in `showToast()` calls replaced with `t('error.generic')`. Italian fallback strings removed from buttons that already used `t()`.
|
||||
- **README Italian phrases** — "La quantità è giusta (2 pz)", "🤖 Spiega", "Latte / Affettato / Panna da cucina", "Buon appetito!", "L'ho buttato" replaced with English equivalents in the README.
|
||||
- **Appliance chips translated** — `renderAppliances()` now shows translated names (e.g. "Air fryer" in EN, "Heißluftfritteuse" in DE) for all known canonical Italian appliance names via `_applianceDisplayName()` lookup. `addApplianceQuick` toast no longer hardcoded Italian. Remove-button title translated.
|
||||
- **Gemini API key not preserved on settings save** — `saveSettings()` was overwriting `s.gemini_key = ""` when the Gemini input field was empty (it is intentionally not pre-populated for security). Key is now preserved if the input is blank. `_geminiAvailable` is re-fetched from the server after every settings save so the recipe buttons reflect the real state immediately.
|
||||
|
||||
## [1.7.14] - 2026-05-16
|
||||
|
||||
### Added
|
||||
- **In-app bug report form** — "Segnala un problema" now opens a modal form instead of redirecting to GitHub. Users can select type (Bug / Feature / Question), write title and description, optionally add reproduction steps. A GitHub issue is created directly with labels and app metadata attached.
|
||||
|
||||
### Fixed
|
||||
- **Kiosk settings button** — "Apri configurazione kiosk" in webapp settings was showing a toast asking to tap a gear icon that no longer exists. Now calls `openNativeSettings()` bridge directly (opens Android SettingsActivity). Fallback for old APKs shows a proper "update the kiosk app" hint.
|
||||
- **False update badge** — `manifest.json` version was `1.7.12` while the app header showed `v1.7.13`, causing the server to report an older deployed version and triggering a spurious update notification.
|
||||
- **Kiosk settings gear disappeared** — Race condition where Kotlin's `onPageFinished` injects `#_kiosk_overlay` before JS runs; JS found the element already present and returned early without ever restoring the native gear button. Fixed: JS no longer hides the native gear on load; `closeModal()` restores it with `setNativeSettingsVisible(true)`.
|
||||
- **`openNativeSettings()` fragile typeof check** — Android `@JavascriptInterface` methods are not always detected as `'function'` by typeof; replaced with try/catch.
|
||||
|
||||
## [1.7.13] - 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
FROM php:8.2-apache-bookworm
|
||||
FROM php:8.2-apache
|
||||
|
||||
# Install required PHP extensions + Tesseract OCR for offline expiry date reading
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libsqlite3-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libonig-dev \
|
||||
libgd-dev \
|
||||
libzip-dev \
|
||||
libicu-dev \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-ita \
|
||||
tesseract-ocr-eng \
|
||||
&& docker-php-ext-install pdo_sqlite curl mbstring gd zip intl \
|
||||
&& docker-php-ext-install pdo_sqlite curl mbstring gd \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Enable Apache mod_rewrite and mod_headers
|
||||
@@ -35,9 +33,7 @@ RUN [ ! -f /var/www/html/.env ] && cp /var/www/html/.env.example /var/www/html/.
|
||||
RUN echo '<Directory /var/www/html>\n\
|
||||
AllowOverride All\n\
|
||||
Require all granted\n\
|
||||
</Directory>\n\
|
||||
# Traefik / reverse-proxy: treat forwarded HTTPS as on so .htaccess does not redirect-loop\n\
|
||||
SetEnvIf X-Forwarded-Proto "https" HTTPS=on' > /etc/apache2/conf-available/evershelf.conf \
|
||||
</Directory>' > /etc/apache2/conf-available/evershelf.conf \
|
||||
&& a2enconf evershelf
|
||||
|
||||
# Expose port 80
|
||||
@@ -47,4 +43,4 @@ EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD curl -f http://localhost/ || exit 1
|
||||
|
||||
CMD ["apache2-foreground"]
|
||||
CMD ["apache2-foreground"]
|
||||
|
||||
@@ -1,223 +1,405 @@
|
||||
# 🏠 EverShelf for Ricardo
|
||||
# 🏠 EverShelf
|
||||
|
||||
> Fork personnalisé d'EverShelf, adapté pour servir de backend stock/recettes à **Ricardo**, l'application bartender. Garde toutes les fonctionnalités d'EverShelf, avec des ajustements pour la gestion de bar (catégorie boissons, sous-catégories alcools, intégration directe avec Ricardo).
|
||||
> **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)
|
||||
[](https://www.php.net/)
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||
[](https://github.com/dadaloop82/EverShelf/discussions)
|
||||
[](https://github.com/dadaloop82/EverShelf/actions/workflows/ci.yml)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Fonctionnalités principales
|
||||
## ✨ Features
|
||||
|
||||
### 📦 Gestion des stocks
|
||||
### 📦 Inventory Management
|
||||
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS
|
||||
- **AI identification** — Take a photo and let Google Gemini identify the product, with suggestions from your existing inventory; gracefully shows a friendly message when AI quota is exhausted instead of a raw API error
|
||||
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
|
||||
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
|
||||
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened; opened-product expiry is now also checked when building banner alerts (not just the dashboard section)
|
||||
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items
|
||||
- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("La quantità è giusta (2 pz)")
|
||||
|
||||
- Inventaire alimentaire complet
|
||||
- Gestion des emplacements :
|
||||
- 🏠 Placard
|
||||
- ❄️ Réfrigérateur
|
||||
- 🧊 Congélateur
|
||||
- 📍 Emplacements personnalisés, entièrement gérés depuis une page **🔧 Configuration** dédiée (ajout, modification, suppression sans toucher au code)
|
||||
- Sous-catégorie dédiée aux boissons (vin, bière, spiritueux, soda, jus, eau...) pour filtrer et trier l'inventaire plus précisément
|
||||
- Scan de codes-barres avec la caméra du téléphone
|
||||
- Ajout rapide de produits
|
||||
- Suivi des dates de péremption
|
||||
- Gestion des produits ouverts
|
||||
- Support des produits sous vide
|
||||
- Détection des incohérences de stock
|
||||
### 🤖 AI-Powered (Google Gemini)
|
||||
- **Expiry date reading** — Photograph a label and extract the expiry date automatically
|
||||
- **Product identification** — Point your camera at any product for instant recognition
|
||||
- **Existing product matching** — AI scan shows matching products already in your pantry before suggesting new ones
|
||||
- **Storage & shelf-life hint** — When adding a new product, Gemini suggests the optimal storage location and shelf-life in the background; shown as an inline AI badge next to the expiry estimate
|
||||
- **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated
|
||||
- **Smart chat assistant** — Ask questions about your inventory, get cooking tips
|
||||
- **Shopping suggestions with tips** — AI-powered purchase recommendations, each enriched with a short practical buying/storing tip
|
||||
- **Anomaly explanation** — "🤖 Spiega" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
|
||||
- **Model fallback** — All AI endpoints try `gemini-2.5-flash` first and fall back to `gemini-2.0-flash` automatically
|
||||
- **Graceful no-key state** — When no Gemini key is configured, AI entry points show a friendly message; the header button is visually greyed with an amber dot
|
||||
|
||||
### 🛒 Shopping List
|
||||
- **Bring! integration** — Sync with the [Bring!](https://www.getbring.com/) shopping list app
|
||||
- **Generic shopping names** — Products are grouped by type ("Latte", "Affettato", "Panna da cucina") rather than brand, keeping the Bring! list clean and consolidated
|
||||
- **Smart predictions** — Know what you'll need before you run out
|
||||
- **Auto-add on depletion** — When a product reaches zero the app adds it to Bring! automatically, no confirmation needed
|
||||
- **Auto-remove on scan** — Products are removed from the shopping list when scanned in - **Auto-migration** — Items already on the Bring! list are silently renamed to their generic name in the background (throttled, runs on list load)
|
||||
- **Catalog coverage** — All product types resolve to a German Bring! catalog key for icon and category display in the Bring! app
|
||||
|
||||
### 🍳 Cooking Mode
|
||||
- **Step-by-step guidance** — Follow recipes with a hands-free cooking interface
|
||||
- **Text-to-Speech** — Voice readout of recipe steps; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.); retries voice loading for up to 10 seconds with a fallback refresh button; TTS activates automatically without requiring the global TTS setting to be enabled
|
||||
- **Auto-read on navigate** — Each step is read aloud automatically when you tap Next or Previous; the first step is read when entering cooking mode
|
||||
- **Timer voice alerts** — 10-second countdown warning spoken aloud before each timer expires; expiry announced vocally when time is up
|
||||
- **Recipe completion** — "Buon appetito!" spoken when the last step is confirmed
|
||||
- **Built-in timer** — Automatic timer suggestions based on recipe instructions
|
||||
- **Ingredient tracking** — Mark ingredients as used during cooking; leftover quantities prompt a "move to another location" flow
|
||||
|
||||
### 📊 Dashboard
|
||||
- **Waste tracking** — Monitor consumed vs. wasted products over 30 days
|
||||
- **Anti-waste report** — Personalised waste rate vs. national average with annual kg estimate; shown above the expiring-items list
|
||||
- **Expiry alerts** — Visual warnings for expired and soon-to-expire items
|
||||
- **Opened products panel** — Tracks partially-used items; expiry is recalculated from the opening date using AI (Gemini) + per-category rule fallback; whole sealed packages always keep their original manufacturer expiry; conf items with mixed whole + fractional units are shown as two separate entries
|
||||
- **Freezer shelf-life** — Granular per-product estimates (USDA/EFSA): fish 120 d, poultry 270 d, whole red-meat cuts 365 d, mince 120 d, vegetables/fruit 270 d, generic 180 d; AI + cache still take priority over rules
|
||||
- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and "L'ho buttato" as the primary action
|
||||
- **Expired product banner** — Products that have passed their effective shelf-life (including opened-product reduced expiry) appear in the top notification banner; icon, colour and title adapt to the actual safety level (✅ green for safe, 👀 amber to check, 🚫 red for danger); high-risk items get a prominent discard action
|
||||
- **Quick recipe bar** — One-tap recipe suggestion using expiring products
|
||||
- **Anomaly banner** — Scrollable banner with suspicious quantities and consumption prediction mismatches, with one-tap correction or inline edit
|
||||
- **Expired/expiring alerts** — Priority-sorted banner notifications for expired and soon-to-expire products with use, throw, edit, and dismiss actions
|
||||
- **Swipe navigation** — Touch swipe or tap arrows/dots to browse banner notifications
|
||||
- **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access
|
||||
|
||||
### 📱 Progressive Web App
|
||||
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
||||
- **Installable** — Add to home screen for a native app experience
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Intelligence artificielle (Google Gemini)
|
||||
## 🚀 Quick Start
|
||||
|
||||
EverShelf peut utiliser l'IA pour :
|
||||
### Prerequisites
|
||||
- **Web server** with PHP 8.0+ (Apache or Nginx)
|
||||
- **PHP extensions**: `pdo_sqlite`, `curl`, `mbstring`, `json`
|
||||
- **HTTPS** recommended (required for camera access on mobile)
|
||||
|
||||
- 📸 Identifier un produit depuis une photo
|
||||
- 📅 Lire automatiquement une date limite de consommation
|
||||
- 🧊 Proposer un stockage adapté
|
||||
- 🍳 Générer des recettes selon votre inventaire
|
||||
- 💬 Répondre aux questions sur vos produits
|
||||
- 🛒 Améliorer les suggestions de courses
|
||||
### Installation
|
||||
|
||||
> L'IA est optionnelle. EverShelf fonctionne sans clé Gemini.
|
||||
|
||||
---
|
||||
|
||||
## 🛒 Liste de courses intelligente
|
||||
|
||||
- Création automatique depuis les ruptures de stock
|
||||
- Prévisions de besoins
|
||||
- Synchronisation avec Bring!
|
||||
- Nettoyage automatique des doublons
|
||||
- Suggestions d'achat personnalisées
|
||||
|
||||
---
|
||||
|
||||
## 🍳 Mode cuisine
|
||||
|
||||
- Recettes étape par étape
|
||||
- Mode mains libres
|
||||
- Synthèse vocale (TTS)
|
||||
- Minuteurs automatiques
|
||||
- Suivi des ingrédients utilisés
|
||||
- Conseils anti-gaspillage
|
||||
|
||||
---
|
||||
|
||||
## ♻️ Réduction du gaspillage
|
||||
|
||||
- Suivi des aliments consommés ou jetés
|
||||
- Analyse des pertes
|
||||
- Alertes de péremption
|
||||
- Suggestions pour utiliser les produits bientôt périmés
|
||||
|
||||
---
|
||||
|
||||
## 🏡 Intégrations
|
||||
|
||||
### Home Assistant
|
||||
|
||||
Intégration native disponible :
|
||||
|
||||
- Capteurs de stock
|
||||
- Dates de péremption
|
||||
- Liste de courses
|
||||
- Calendrier des produits
|
||||
- Actions personnalisées
|
||||
- Suggestions de recettes IA
|
||||
|
||||
Compatible avec une installation 100% locale.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Application mobile / PWA
|
||||
|
||||
- Interface adaptée smartphone
|
||||
- Installation comme une application
|
||||
- Synchronisation multi-appareils
|
||||
- Mode hors-ligne :
|
||||
- consultation du stock
|
||||
- actions mises en attente
|
||||
- synchronisation automatique au retour réseau
|
||||
|
||||
---
|
||||
|
||||
## 📺 Mode tablette (Kiosque Android)
|
||||
|
||||
- Affichage plein écran
|
||||
- Verrouillage kiosque
|
||||
- Scan caméra
|
||||
- Support TTS natif Android
|
||||
- Découverte automatique du serveur
|
||||
- Support des balances Bluetooth
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation rapide
|
||||
|
||||
### Prérequis
|
||||
|
||||
- PHP 8.0+
|
||||
- SQLite 3
|
||||
- Extensions PHP :
|
||||
- `pdo_sqlite`
|
||||
- `curl`
|
||||
- `mbstring`
|
||||
- `json`
|
||||
|
||||
Docker est recommandé.
|
||||
|
||||
---
|
||||
|
||||
### 🐳 Installation Docker (CLI)
|
||||
#### Option A: Docker (recommended)
|
||||
|
||||
```bash
|
||||
git clone https://git.mashome.fr/morgane/EverShelf.git
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/dadaloop82/EverShelf.git
|
||||
cd EverShelf
|
||||
|
||||
# 2. Create configuration file
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
# 3. Start with Docker Compose
|
||||
docker compose up -d
|
||||
|
||||
# → Open http://localhost:8080
|
||||
```
|
||||
|
||||
Puis ouvrez :
|
||||
http://localhost:8080
|
||||
#### Option B: Manual
|
||||
|
||||
---
|
||||
### 🐳 Déploiement via Portainer
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/dadaloop82/EverShelf.git
|
||||
cd EverShelf
|
||||
|
||||
1. Dans Portainer, va dans **Stacks** → **Add stack**
|
||||
2. Donne un nom à la stack (ex : `evershelf`)
|
||||
3. Colle le contenu de ton `docker-compose.yml` dans l'éditeur web (ou utilise l'option **Repository** en pointant vers `https://git.mashome.fr/morgane/EverShelf.git` et le chemin du fichier compose)
|
||||
4. Renseigne tes variables d'environnement dans la section **Environment variables** (ou via un fichier `.env` à la racine du repo)
|
||||
5. Clique sur **Deploy the stack**
|
||||
# 2. Create configuration file
|
||||
cp .env.example .env
|
||||
|
||||
Pour mettre à jour après une modification de code :
|
||||
- Va dans **Stacks** → ta stack → **Update the stack**
|
||||
- Coche bien **« Re-pull image »** / **« Re-build image »** avant de valider — sinon Portainer redémarre le conteneur avec l'image déjà construite en cache, sans prendre en compte tes changements.
|
||||
# 3. Set permissions
|
||||
chmod 755 data/
|
||||
chmod 664 data/.gitkeep
|
||||
chown -R www-data:www-data data/
|
||||
|
||||
---
|
||||
# 4. Edit your configuration
|
||||
nano .env
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
### Configuration (.env)
|
||||
|
||||
Exemple de fichier `.env` :
|
||||
```ini
|
||||
# Required for AI features (get a key at https://aistudio.google.com/app/apikey)
|
||||
GEMINI_API_KEY=your_api_key_here
|
||||
|
||||
```env
|
||||
# IA Google Gemini (optionnel)
|
||||
GEMINI_API_KEY=votre_cle
|
||||
# Optional: Bring! shopping list integration
|
||||
BRING_EMAIL=your_email@example.com
|
||||
BRING_PASSWORD=your_password
|
||||
|
||||
# Bring! (optionnel)
|
||||
BRING_EMAIL=email@example.com
|
||||
BRING_PASSWORD=motdepasse
|
||||
# Optional: Text-to-Speech for cooking mode
|
||||
TTS_URL=http://your-home-assistant:8123/api/events/tts_speak
|
||||
TTS_TOKEN=your_long_lived_token
|
||||
TTS_ENABLED=true
|
||||
|
||||
# Sécurité API
|
||||
API_TOKEN=
|
||||
# Optional: Security — protect the save_settings endpoint
|
||||
# Set a strong random string; the Settings UI will ask for it before saving
|
||||
SETTINGS_TOKEN=
|
||||
|
||||
# Nettoyage automatique
|
||||
RECIPE_RETENTION_DAYS=7
|
||||
TRANSACTION_RETENTION_DAYS=90
|
||||
# Optional: Demo mode — block all write operations at the router level
|
||||
DEMO_MODE=false
|
||||
```
|
||||
|
||||
### Web Server Configuration
|
||||
|
||||
<details>
|
||||
<summary><strong>Apache (.htaccess)</strong></summary>
|
||||
|
||||
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
|
||||
<Directory /var/www/html/evershelf>
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Nginx</strong></summary>
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-server.local;
|
||||
root /var/www/html/evershelf;
|
||||
index index.html;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
# Deny access to sensitive files
|
||||
location ~ /\.env { deny all; }
|
||||
location ~ /data/ { deny all; }
|
||||
location ~ /backup\.sh { deny all; }
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### HTTPS Setup (Recommended)
|
||||
|
||||
Camera access requires HTTPS on most mobile browsers. Options:
|
||||
- **Let's Encrypt** with Certbot (for public-facing servers)
|
||||
- **Self-signed certificate** (for local network only)
|
||||
- **Reverse proxy** (e.g., Caddy, Traefik) with automatic TLS
|
||||
|
||||
### Cron Job (Optional)
|
||||
|
||||
Set up a cron job for smart shopping predictions:
|
||||
|
||||
```bash
|
||||
# Run every 5 minutes
|
||||
*/5 * * * * php /path/to/evershelf/api/cron_smart_shopping.php >> /path/to/evershelf/data/cron.log 2>&1
|
||||
```
|
||||
|
||||
### Backup (Optional)
|
||||
|
||||
The included `backup.sh` creates local daily backups of your database:
|
||||
|
||||
```bash
|
||||
# Run daily at 3 AM
|
||||
0 3 * * * /path/to/evershelf/backup.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Vie privée
|
||||
## 🏗️ Architecture
|
||||
|
||||
EverShelf est conçu pour fonctionner en auto-hébergement :
|
||||
```
|
||||
evershelf/
|
||||
├── index.html # Single-page application (SPA)
|
||||
├── manifest.json # PWA manifest
|
||||
├── .env.example # Configuration template
|
||||
├── backup.sh # Local database backup script
|
||||
├── LICENSE # MIT License
|
||||
│
|
||||
├── api/
|
||||
│ ├── index.php # Main API router (all endpoints)
|
||||
│ ├── database.php # SQLite schema, migrations, helpers
|
||||
│ └── cron_smart_shopping.php # Background job for predictions
|
||||
│
|
||||
├── assets/
|
||||
│ ├── css/style.css # All application styles
|
||||
│ ├── js/app.js # All application logic
|
||||
│ └── img/ # Static images
|
||||
│
|
||||
└── data/ # Runtime data (gitignored)
|
||||
├── evershelf.db # SQLite database (auto-created)
|
||||
├── backups/ # Local DB backups
|
||||
└── *.json # Token/cache files
|
||||
|
||||
- Pas de compte obligatoire
|
||||
- Pas de cloud imposé
|
||||
- Données stockées localement
|
||||
- SQLite comme base de données
|
||||
- Les fonctions IA utilisent uniquement les services configurés par l'utilisateur
|
||||
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
|
||||
|
||||
| Category | Action | Method | Description |
|
||||
|----------|--------|--------|-------------|
|
||||
| **Products** | `search_barcode` | GET | Find product by barcode |
|
||||
| | `lookup_barcode` | GET | Look up barcode on Open Food Facts |
|
||||
| | `product_save` | POST | Create or update a product |
|
||||
| | `products_list` | GET | List all products |
|
||||
| **Inventory** | `inventory_list` | GET | List inventory items |
|
||||
| | `inventory_add` | POST | Add product to inventory |
|
||||
| | `inventory_use` | POST | Use/consume from inventory |
|
||||
| | `inventory_summary` | GET | Count by location |
|
||||
| **AI** | `gemini_identify` | POST | Identify product from photo |
|
||||
| | `gemini_expiry` | POST | Read expiry date from photo |
|
||||
| | `gemini_chat` | POST | Chat with AI assistant |
|
||||
| | `generate_recipe` | POST | Generate recipe from inventory |
|
||||
| | `gemini_product_hint` | POST | Storage location + shelf-life hint |
|
||||
| | `gemini_shopping_enrich` | POST | Enrich shopping suggestions with tips |
|
||||
| | `gemini_anomaly_explain` | POST | Plain-language anomaly explanation |
|
||||
| **Shopping** | `bring_list` | GET | Get Bring! shopping list |
|
||||
| | `bring_add` | POST | Add items to Bring! |
|
||||
| | `smart_shopping` | GET | Smart shopping predictions |
|
||||
| **Settings** | `get_settings` | GET | Get server configuration |
|
||||
| | `save_settings` | POST | Update server configuration |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Développement
|
||||
## 🔒 Security Notes
|
||||
|
||||
Technologies principales :
|
||||
|
||||
- PHP
|
||||
- SQLite
|
||||
- JavaScript
|
||||
- HTML/CSS
|
||||
- Docker
|
||||
- **Credentials** are stored in `.env` (server-side, never committed to Git)
|
||||
- **Database** stays local — never pushed to remote repositories
|
||||
- **API keys are never exposed to the browser** — `get_settings` returns only boolean flags (`gemini_key_set`, `settings_token_set`), never raw key values
|
||||
- **Settings write protection** — set `SETTINGS_TOKEN` in `.env` to require a secret token (`X-Settings-Token` header) for all `save_settings` calls; validated with `hash_equals` to prevent timing attacks
|
||||
- **Demo / public mode** — set `DEMO_MODE=true` to block all write operations at the PHP router level before any business logic runs
|
||||
- The API uses **parameterized SQL queries** (PDO prepared statements) against injection
|
||||
- **Input validation** on all inventory operations (quantity bounds, location whitelist)
|
||||
- Consider adding **reverse-proxy authentication** (e.g. Authelia, Nginx `auth_basic`) if the server is accessible from the internet
|
||||
|
||||
---
|
||||
|
||||
## 📜 Licence
|
||||
## 🛠️ Development
|
||||
|
||||
Projet sous licence MIT.
|
||||
```bash
|
||||
# Run PHP's built-in server for local development
|
||||
php -S localhost:8080 -t /path/to/evershelf
|
||||
|
||||
# Check PHP syntax
|
||||
php -l api/index.php
|
||||
php -l api/database.php
|
||||
```
|
||||
|
||||
The application uses no build tools — edit files directly and refresh.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Crédits
|
||||
## 📋 Roadmap
|
||||
|
||||
Ce fork, **EverShelf for Ricardo**, est maintenu par Morgane pour servir de système de gestion de stock/recettes à l'application **Ricardo**.
|
||||
- [ ] User authentication / multi-user support
|
||||
- [ ] Offline mode with service worker
|
||||
- [ ] Export/import inventory data
|
||||
- [ ] Notification system (Telegram, email) for expiring products
|
||||
|
||||
Projet original :
|
||||
---
|
||||
|
||||
https://github.com/dadaloop82/EverShelf
|
||||
## 🌐 Translations
|
||||
|
||||
Ce dépôt contient des améliorations et adaptations personnelles, incluant un système d'export/import avec fusion intelligente des données.
|
||||
The app supports multiple languages via JSON translation files in the `translations/` folder.
|
||||
|
||||
| Language | Status |
|
||||
|----------|--------|
|
||||
| 🇮🇹 Italian (it) | ✅ Complete (base) |
|
||||
| 🇬🇧 English (en) | ✅ Complete |
|
||||
| 🇩🇪 German (de) | ✅ Complete |
|
||||
|
||||
**Want to add your language?** See the [Translation Guide](CONTRIBUTING.md#-adding-translations) — just copy `translations/it.json`, translate the values, and submit a PR!
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add my feature'`)
|
||||
4. Push to the branch (`git push origin feature/my-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the **MIT License** — see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
## 👨💻 Author
|
||||
|
||||
**Stimpfl Daniel** — [evershelfproject@gmail.com](mailto:evershelfproject@gmail.com)
|
||||
|
||||
- Website: [evershelfproject.dadaloop.it](https://evershelfproject.dadaloop.it/)
|
||||
- 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!
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf API bootstrap — shared by HTTP router and cron.
|
||||
*/
|
||||
// Never emit HTML notices before JSON API responses (breaks fetch().json() in the PWA).
|
||||
if (!defined('CRON_MODE') && (getenv('DISPLAY_ERRORS') ?: '') !== '1') {
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('html_errors', '0');
|
||||
}
|
||||
require_once __DIR__ . '/lib/env.php';
|
||||
require_once __DIR__ . '/lib/constants.php';
|
||||
require_once __DIR__ . '/lib/github.php';
|
||||
require_once __DIR__ . '/lib/security.php';
|
||||
require_once __DIR__ . '/lib/cron_log.php';
|
||||
require_once __DIR__ . '/logger.php';
|
||||
require_once __DIR__ . '/database.php';
|
||||
@@ -11,16 +11,14 @@ if (PHP_SAPI !== 'cli') {
|
||||
exit('Forbidden');
|
||||
}
|
||||
|
||||
// Define CRON_MODE before loading bootstrap so the HTTP router is skipped
|
||||
// Define CRON_MODE before loading index.php so the router is skipped
|
||||
define('CRON_MODE', true);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
// Load all API functions without running the HTTP router
|
||||
require_once __DIR__ . '/index.php';
|
||||
|
||||
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
|
||||
|
||||
evershelfRotateCronLog();
|
||||
|
||||
try {
|
||||
$db = getDB();
|
||||
|
||||
@@ -44,10 +42,9 @@ try {
|
||||
$itemCount = count($decoded['items'] ?? []);
|
||||
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . $itemCount . " items cached\n";
|
||||
|
||||
// ── Bring! server-side sync ───────────────────────────────────────────
|
||||
// After computing smart shopping, remove stale Bring! items and push every
|
||||
// product that needs restocking (esauriti, quasi finiti, previsione).
|
||||
// Runs fully server-side every cron cycle (~5 min).
|
||||
// ── 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'])) {
|
||||
@@ -58,21 +55,6 @@ try {
|
||||
. ($cleanupResult['errors'] ? ', errors: ' . $cleanupResult['errors'] : '') . "\n";
|
||||
}
|
||||
|
||||
$dedupeResult = bringDedupeGenerics($db);
|
||||
if (isset($dedupeResult['skipped'])) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe skipped: ' . $dedupeResult['skipped'] . "\n";
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe — removed: ' . ($dedupeResult['removed'] ?? 0)
|
||||
. ', merged specs: ' . ($dedupeResult['merged'] ?? 0) . "\n";
|
||||
}
|
||||
|
||||
$specsResult = bringSyncSpecs($db);
|
||||
if (isset($specsResult['skipped'])) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! specs skipped: ' . $specsResult['skipped'] . "\n";
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! specs — updated: ' . ($specsResult['updated'] ?? 0) . "\n";
|
||||
}
|
||||
|
||||
$addResult = bringAutoAddCritical($db);
|
||||
if (isset($addResult['skipped'])) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add skipped: ' . $addResult['skipped'] . "\n";
|
||||
@@ -80,11 +62,6 @@ try {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add — added: ' . ($addResult['added'] ?? 0)
|
||||
. ', updated specs: ' . ($addResult['updated'] ?? 0) . "\n";
|
||||
}
|
||||
|
||||
$dedupeFinal = bringDedupeGenerics($db);
|
||||
if (!isset($dedupeFinal['skipped']) && (($dedupeFinal['removed'] ?? 0) > 0)) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! dedupe (final) — removed: ' . ($dedupeFinal['removed'] ?? 0) . "\n";
|
||||
}
|
||||
} catch (Throwable $be) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n";
|
||||
}
|
||||
@@ -102,53 +79,6 @@ try {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm warning: ' . $pe->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// ── DB cleanup (retention policy) ────────────────────────────────────
|
||||
// Delete old recipes and transactions based on .env retention settings.
|
||||
try {
|
||||
ob_start();
|
||||
dbCleanup($db);
|
||||
ob_end_clean();
|
||||
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup done'
|
||||
. ' (recipes >' . env('RECIPE_RETENTION_DAYS','7') . 'd'
|
||||
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','90') . 'd' . ")\n";
|
||||
} catch (Throwable $ce) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup warning: ' . $ce->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// ── Daily incremental backup ──────────────────────────────────────────
|
||||
// Create a local backup at most once every 23 h; also push to Google Drive
|
||||
// if GDRIVE_ENABLED=true. The guard prevents multiple backups per day even
|
||||
// though the cron runs every 5 minutes.
|
||||
if (env('BACKUP_ENABLED', 'true') === 'true') {
|
||||
try {
|
||||
$lastBackupTs = 0;
|
||||
if (file_exists(BACKUP_LAST_TS_PATH)) {
|
||||
$lastData = json_decode(file_get_contents(BACKUP_LAST_TS_PATH), true) ?: [];
|
||||
$lastBackupTs = (int)($lastData['ts'] ?? 0);
|
||||
}
|
||||
if (time() - $lastBackupTs >= 82800) { // 23 h
|
||||
$backupResult = createLocalBackup($db);
|
||||
if ($backupResult['success']) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup local: ' . $backupResult['filename']
|
||||
. ' (' . $backupResult['size_kb'] . 'KB, purged ' . $backupResult['purged'] . " old)\n";
|
||||
if (env('GDRIVE_ENABLED', 'false') === 'true') {
|
||||
$gResult = backupToGDrive($db);
|
||||
if ($gResult['success']) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive: OK'
|
||||
. ' (purged remote: ' . ($gResult['purged_remote'] ?? 0) . ")\n";
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive warning: ' . ($gResult['error'] ?? 'unknown') . "\n";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup warning: ' . ($backupResult['error'] ?? 'unknown') . "\n";
|
||||
}
|
||||
}
|
||||
} catch (Throwable $be) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup error: ' . $be->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$msg = $e->getMessage();
|
||||
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
|
||||
@@ -156,120 +86,3 @@ try {
|
||||
_phpErrorReport($msg, $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Home Assistant: expiry alerts ─────────────────────────────────────────────
|
||||
// Fire one HA webhook per expiring item (once per day guard via a simple flag file).
|
||||
if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
|
||||
try {
|
||||
$haFlagFile = __DIR__ . '/../data/ha_expiry_notified_' . date('Y-m-d') . '.json';
|
||||
if (!file_exists($haFlagFile)) {
|
||||
$expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3'));
|
||||
$expiringItems = $db->query(
|
||||
"SELECT p.id AS product_id, i.id AS inventory_id,
|
||||
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
|
||||
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
|
||||
ORDER BY i.expiry_date ASC LIMIT 20"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$expiredItems = $db->query(
|
||||
"SELECT p.id AS product_id, i.id AS inventory_id,
|
||||
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
|
||||
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date < date('now')
|
||||
ORDER BY i.expiry_date ASC LIMIT 10"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Normalise rows to full product format
|
||||
if (!function_exists('_haFormatProduct')) {
|
||||
function _haFormatProduct(array $row): array {
|
||||
$daysRemaining = null;
|
||||
if (!empty($row['expiry_date'])) {
|
||||
$diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date']));
|
||||
$daysRemaining = (int)$diff->format('%r%a');
|
||||
}
|
||||
return [
|
||||
'product_id' => (int)($row['product_id'] ?? 0),
|
||||
'inventory_id' => (int)($row['inventory_id'] ?? 0),
|
||||
'name' => $row['name'],
|
||||
'brand' => $row['brand'] ?? null,
|
||||
'category' => $row['category'] ?? null,
|
||||
'quantity' => (float)($row['quantity'] ?? 0),
|
||||
'unit' => $row['unit'] ?? '',
|
||||
'default_quantity' => (float)($row['default_quantity'] ?? 0),
|
||||
'package_unit' => $row['package_unit'] ?? null,
|
||||
'location' => $row['location'] ?? null,
|
||||
'expiry_date' => $row['expiry_date'] ?? null,
|
||||
'days_remaining' => $daysRemaining,
|
||||
'opened_at' => $row['opened_at'] ?? null,
|
||||
'vacuum_sealed' => !empty($row['vacuum_sealed']),
|
||||
];
|
||||
}
|
||||
}
|
||||
$expiringItems = array_map('_haFormatProduct', $expiringItems);
|
||||
$expiredItems = array_map('_haFormatProduct', $expiredItems);
|
||||
|
||||
if (!empty($expiringItems)) {
|
||||
$names = implode(', ', array_column($expiringItems, 'name'));
|
||||
_fireHaWebhook('expiry_alert', [
|
||||
'count' => count($expiringItems),
|
||||
'items' => $expiringItems,
|
||||
'type' => 'expiring_soon',
|
||||
'days' => $expiryDays,
|
||||
'summary' => $names,
|
||||
]);
|
||||
// Also send HA notification if service configured
|
||||
if (env('HA_NOTIFY_SERVICE', '') !== '') {
|
||||
$msg = count($expiringItems) . ' product(s) expiring within ' . $expiryDays . ' days: ' . $names;
|
||||
_sendHaNotify($msg, ['expiring_items' => $expiringItems]);
|
||||
}
|
||||
echo '[' . date('Y-m-d H:i:s') . '] HA expiry_alert fired: ' . count($expiringItems) . " items\n";
|
||||
}
|
||||
|
||||
if (!empty($expiredItems)) {
|
||||
$expNames = implode(', ', array_column($expiredItems, 'name'));
|
||||
_fireHaWebhook('expiry_alert', [
|
||||
'count' => count($expiredItems),
|
||||
'items' => $expiredItems,
|
||||
'type' => 'expired',
|
||||
'summary' => $expNames,
|
||||
]);
|
||||
echo '[' . date('Y-m-d H:i:s') . '] HA expired fired: ' . count($expiredItems) . " items\n";
|
||||
}
|
||||
|
||||
// Mark as done for today
|
||||
file_put_contents($haFlagFile, json_encode(['ts' => time(), 'expiring' => count($expiringItems ?? []), 'expired' => count($expiredItems ?? [])]));
|
||||
// Clean up old flag files (keep last 7 days)
|
||||
foreach (glob(__DIR__ . '/../data/ha_expiry_notified_*.json') as $oldFlag) {
|
||||
$flagDate = str_replace([__DIR__ . '/../data/ha_expiry_notified_', '.json'], '', $oldFlag);
|
||||
if ($flagDate < date('Y-m-d', strtotime('-7 days'))) @unlink($oldFlag);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $haE) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] HA expiry hook warning: ' . $haE->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Avahi/mDNS discovery registration ─────────────────────────────────────────
|
||||
// If avahi-daemon is running on this host, register the _evershelf._tcp service
|
||||
// so that Home Assistant can auto-discover this instance via Zeroconf.
|
||||
if (function_exists('shell_exec')) {
|
||||
try {
|
||||
$avahiService = '/etc/avahi/services/evershelf.xml';
|
||||
// Only create/update if avahi-daemon is installed and the file doesn't exist yet
|
||||
if (!file_exists($avahiService) && (shell_exec('which avahi-daemon 2>/dev/null') || shell_exec('which avahi-publish 2>/dev/null'))) {
|
||||
$template = __DIR__ . '/../docker/avahi-evershelf.xml';
|
||||
if (file_exists($template)) {
|
||||
$xml = file_get_contents($template);
|
||||
@file_put_contents($avahiService, $xml);
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Avahi mDNS service registered at ' . $avahiService . "\n";
|
||||
}
|
||||
}
|
||||
} catch (Throwable $avahiE) {
|
||||
// Non-fatal: avahi not available
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,39 +38,11 @@ function _ensureDataDir(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure the SQLite DB and WAL sidecar files are writable (Docker volume first-boot). */
|
||||
function _ensureDbWritable(): void {
|
||||
if (!file_exists(DB_PATH)) {
|
||||
return;
|
||||
}
|
||||
if (!is_writable(DB_PATH)) {
|
||||
@chmod(DB_PATH, 0664);
|
||||
}
|
||||
foreach ([DB_PATH . '-wal', DB_PATH . '-shm'] as $sidecar) {
|
||||
if (file_exists($sidecar) && !is_writable($sidecar)) {
|
||||
@chmod($sidecar, 0664);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDB(): PDO {
|
||||
_ensureDataDir();
|
||||
_ensureDbWritable();
|
||||
// logger.php is required by index.php before getDB() is called.
|
||||
// In cron context it may not be loaded yet — guard with class_exists.
|
||||
$useLogging = class_exists('LoggingPDO', false);
|
||||
$isNew = !file_exists(DB_PATH);
|
||||
$db = $useLogging
|
||||
? new LoggingPDO('sqlite:' . DB_PATH)
|
||||
: new PDO('sqlite:' . DB_PATH);
|
||||
$db = new PDO('sqlite:' . DB_PATH);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
// Set a busy timeout to prevent "database is locked" errors under high concurrency.
|
||||
// This gives SQLite up to 5 seconds to acquire a lock before throwing an exception.
|
||||
$db->setAttribute(PDO::ATTR_TIMEOUT, 5); // PDO::ATTR_TIMEOUT is in seconds for MySQL, but not directly for SQLite.
|
||||
// For SQLite, we use PRAGMA busy_timeout.
|
||||
$db->exec('PRAGMA journal_mode = WAL;');
|
||||
$db->exec('PRAGMA busy_timeout = 10000;'); // 10 s — cron + PWA writes can contend under WAL
|
||||
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec("PRAGMA journal_mode=WAL");
|
||||
$db->exec("PRAGMA foreign_keys=ON");
|
||||
@@ -88,29 +60,6 @@ function getDB(): PDO {
|
||||
return $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a DB write when SQLite returns "database is locked" (concurrent cron + API).
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $fn
|
||||
* @return T
|
||||
*/
|
||||
function dbWithRetry(callable $fn, int $maxAttempts = 4): mixed {
|
||||
$attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return $fn();
|
||||
} catch (\PDOException $e) {
|
||||
$attempt++;
|
||||
$locked = str_contains($e->getMessage(), 'database is locked');
|
||||
if (!$locked || $attempt >= $maxAttempts) {
|
||||
throw $e;
|
||||
}
|
||||
usleep(150000 * $attempt); // 150 ms, 300 ms, 450 ms …
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initializeDB(PDO $db): void {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
@@ -165,16 +114,6 @@ function initializeDB(PDO $db): void {
|
||||
}
|
||||
|
||||
function migrateDB(PDO $db): void {
|
||||
// Guard: if core tables don't exist yet (e.g. DB file present but empty / partial init),
|
||||
// run initializeDB first so all tables are created, then return — no ALTER TABLE needed.
|
||||
$productsExists = $db->query(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='products'"
|
||||
)->fetchColumn();
|
||||
if (!$productsExists) {
|
||||
initializeDB($db);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add package_unit column if missing
|
||||
$cols = $db->query("PRAGMA table_info(products)")->fetchAll();
|
||||
$colNames = array_column($cols, 'name');
|
||||
@@ -186,28 +125,6 @@ function migrateDB(PDO $db): void {
|
||||
try { $db->exec("ALTER TABLE products ADD COLUMN shopping_name TEXT DEFAULT ''"); }
|
||||
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||
}
|
||||
if (!in_array('subcategory', $colNames)) {
|
||||
try { $db->exec("ALTER TABLE products ADD COLUMN subcategory TEXT DEFAULT NULL"); }
|
||||
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||
}
|
||||
|
||||
// Empty barcode strings break UNIQUE (only one '' allowed); normalize to NULL.
|
||||
$db->exec("UPDATE products SET barcode = NULL WHERE barcode IS NOT NULL AND TRIM(barcode) = ''");
|
||||
|
||||
$invCols = $db->query("PRAGMA table_info(inventory)")->fetchAll();
|
||||
$invColNames = array_column($invCols, 'name');
|
||||
if (!in_array('expiry_user_set', $invColNames)) {
|
||||
try { $db->exec("ALTER TABLE inventory ADD COLUMN expiry_user_set INTEGER DEFAULT 0"); }
|
||||
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||
}
|
||||
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS barcode_cache (
|
||||
barcode TEXT PRIMARY KEY,
|
||||
found INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT,
|
||||
payload TEXT,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
|
||||
// Migrate transactions CHECK constraint to allow 'waste' type
|
||||
$sql = $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='transactions'")->fetchColumn();
|
||||
@@ -221,14 +138,11 @@ function migrateDB(PDO $db): void {
|
||||
quantity REAL NOT NULL,
|
||||
location TEXT NOT NULL DEFAULT 'dispensa',
|
||||
notes TEXT DEFAULT '',
|
||||
undone INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
||||
)
|
||||
");
|
||||
// Insert with explicit columns: transactions_old may lack 'undone' (pre-v1.7.x DB)
|
||||
$db->exec("INSERT INTO transactions (id, product_id, type, quantity, location, notes, created_at)
|
||||
SELECT id, product_id, type, quantity, location, notes, created_at FROM transactions_old");
|
||||
$db->exec("INSERT INTO transactions SELECT * FROM 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_date ON transactions(created_at)");
|
||||
@@ -322,263 +236,6 @@ function migrateDB(PDO $db): void {
|
||||
// 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)");
|
||||
|
||||
// Custom locations table (v1.9.0) — dynamic inventory locations managed from Settings
|
||||
$locTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='locations'")->fetchAll();
|
||||
if (empty($locTables)) {
|
||||
$db->exec("
|
||||
CREATE TABLE locations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
icon TEXT DEFAULT '📦',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_builtin INTEGER DEFAULT 0
|
||||
);
|
||||
");
|
||||
|
||||
$db->exec("INSERT INTO locations (key, label, icon, sort_order, is_builtin) VALUES
|
||||
('dispensa', 'Dispensa', '🗄️', 1, 1),
|
||||
('frigo', 'Frigo', '🧊', 2, 1),
|
||||
('freezer', 'Freezer', '❄️', 3, 1),
|
||||
('altro', 'Altro', '📦', 4, 1)
|
||||
");
|
||||
}
|
||||
|
||||
// Custom subcategories table (v2.0) — sous-catégories par catégorie, gérables depuis Config
|
||||
$subcatTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='subcategories'")->fetchAll();
|
||||
if (empty($subcatTables)) {
|
||||
$db->exec("
|
||||
CREATE TABLE subcategories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
UNIQUE(category, key)
|
||||
);
|
||||
");
|
||||
$db->exec("INSERT INTO subcategories (category, key, label, sort_order) VALUES
|
||||
('latticini', 'lait', '🥛 Lait', 1),
|
||||
('latticini', 'yaourt', '🥣 Yaourt', 2),
|
||||
('latticini', 'fromage', '🧀 Fromage', 3),
|
||||
('latticini', 'beurre', '🧈 Beurre', 4),
|
||||
('latticini', 'creme', '🍦 Crème', 5),
|
||||
('latticini', 'oeufs', '🥚 Œufs', 6),
|
||||
('latticini', 'autre', '📦 Autre', 7),
|
||||
('carne', 'poulet', '🍗 Poulet', 1),
|
||||
('carne', 'boeuf', '🐄 Bœuf', 2),
|
||||
('carne', 'porc', '🐖 Porc', 3),
|
||||
('carne', 'agneau', '🐑 Agneau', 4),
|
||||
('carne', 'charcuterie', '🥓 Charcuterie', 5),
|
||||
('carne', 'autre', '📦 Autre', 6),
|
||||
('pesce', 'poisson_frais', '🐟 Poisson frais', 1),
|
||||
('pesce', 'poisson_surgele', '🧊 Poisson surgelé', 2),
|
||||
('pesce', 'fruits_mer', '🦐 Fruits de mer', 3),
|
||||
('pesce', 'conserve_poisson', '🥫 Conserve', 4),
|
||||
('pesce', 'autre', '📦 Autre', 5),
|
||||
('frutta', 'agrumes', '🍊 Agrumes', 1),
|
||||
('frutta', 'baies', '🫐 Baies', 2),
|
||||
('frutta', 'fruits_noyau', '🍑 Fruits à noyau', 3),
|
||||
('frutta', 'fruits_tropicaux', '🍍 Fruits tropicaux', 4),
|
||||
('frutta', 'autre', '📦 Autre', 5),
|
||||
('verdura', 'legumes_feuilles', '🥬 Légumes feuilles', 1),
|
||||
('verdura', 'legumes_racines', '🥕 Légumes racines', 2),
|
||||
('verdura', 'legumineuses_fraiches', '🌱 Légumineuses fraîches', 3),
|
||||
('verdura', 'autre', '📦 Autre', 4),
|
||||
('pasta', 'pates', '🍝 Pâtes', 1),
|
||||
('pasta', 'riz', '🍚 Riz', 2),
|
||||
('pasta', 'semoule', '🌾 Semoule', 3),
|
||||
('pasta', 'autre', '📦 Autre', 4),
|
||||
('pane', 'pain_frais', '🍞 Pain frais', 1),
|
||||
('pane', 'biscottes', '🥖 Biscottes', 2),
|
||||
('pane', 'viennoiserie', '🥐 Viennoiserie', 3),
|
||||
('pane', 'autre', '📦 Autre', 4),
|
||||
('surgelati', 'plats_prepares', '🍱 Plats préparés', 1),
|
||||
('surgelati', 'legumes_surgeles', '🧊 Légumes surgelés', 2),
|
||||
('surgelati', 'glaces', '🍨 Glaces', 3),
|
||||
('surgelati', 'viande_poisson_surgele', '🧊 Viande/poisson surgelé', 4),
|
||||
('surgelati', 'autre', '📦 Autre', 5),
|
||||
('bevande', 'vin', '🍷 Vin', 1),
|
||||
('bevande', 'biere', '🍺 Bière', 2),
|
||||
('bevande', 'spiritueux', '🥃 Spiritueux', 3),
|
||||
('bevande', 'soda', '🥤 Soda', 4),
|
||||
('bevande', 'jus', '🧃 Jus', 5),
|
||||
('bevande', 'eau', '💧 Eau', 6),
|
||||
('bevande', 'autre', '📦 Autre', 7),
|
||||
('condimenti', 'huile', '🫒 Huile', 1),
|
||||
('condimenti', 'vinaigre', '🍶 Vinaigre', 2),
|
||||
('condimenti', 'sauce', '🥫 Sauce', 3),
|
||||
('condimenti', 'epice', '🌿 Épice', 4),
|
||||
('condimenti', 'autre', '📦 Autre', 5),
|
||||
('snack', 'chocolat', '🍫 Chocolat', 1),
|
||||
('snack', 'biscuit', '🍪 Biscuit', 2),
|
||||
('snack', 'chips', '🥔 Chips', 3),
|
||||
('snack', 'bonbon', '🍬 Bonbon', 4),
|
||||
('snack', 'autre', '📦 Autre', 5),
|
||||
('conserve', 'legumes_conserve', '🥫 Légumes', 1),
|
||||
('conserve', 'fruits_conserve', '🥫 Fruits', 2),
|
||||
('conserve', 'poisson_conserve', '🥫 Poisson', 3),
|
||||
('conserve', 'confiture', '🍯 Confiture', 4),
|
||||
('conserve', 'autre', '📦 Autre', 5),
|
||||
('cereali', 'cereales_petitdej', '🥣 Céréales petit-déj', 1),
|
||||
('cereali', 'legumineuses_seches', '🫘 Légumineuses sèches', 2),
|
||||
('cereali', 'farine', '🌾 Farine', 3),
|
||||
('cereali', 'autre', '📦 Autre', 4)
|
||||
");
|
||||
}
|
||||
|
||||
// Custom categories table (v2.1) — catégories produit, gérables depuis Config
|
||||
$catTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='categories'")->fetchAll();
|
||||
if (empty($catTables)) {
|
||||
$db->exec("
|
||||
CREATE TABLE categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
icon TEXT DEFAULT '📦',
|
||||
keywords TEXT DEFAULT '',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_builtin INTEGER DEFAULT 0
|
||||
);
|
||||
");
|
||||
$db->exec("INSERT INTO categories (key, label, icon, sort_order, is_builtin) VALUES
|
||||
('latticini', 'Latticini', '🥛', 1, 1),
|
||||
('carne', 'Carne', '🥩', 2, 1),
|
||||
('pesce', 'Pesce', '🐟', 3, 1),
|
||||
('frutta', 'Frutta', '🍎', 4, 1),
|
||||
('verdura', 'Verdura', '🥬', 5, 1),
|
||||
('pasta', 'Pasta', '🍝', 6, 1),
|
||||
('pane', 'Pane', '🍞', 7, 1),
|
||||
('surgelati', 'Surgelati', '🧊', 8, 1),
|
||||
('bevande', 'Bevande', '🥤', 9, 1),
|
||||
('condimenti', 'Condimenti', '🧂', 10, 1),
|
||||
('snack', 'Snack', '🍪', 11, 1),
|
||||
('conserve', 'Conserve', '🥫', 12, 1),
|
||||
('cereali', 'Cereali', '🌾', 13, 1),
|
||||
('igiene', 'Igiene', '🧴', 14, 1),
|
||||
('pulizia', 'Pulizia', '🧹', 15, 1),
|
||||
('altro', 'Altro', '📦', 16, 1)
|
||||
");
|
||||
}
|
||||
|
||||
// Recipe library (v2.2) — recettes ajoutées manuellement (cocktails, boissons...), distinctes du planning repas
|
||||
$recipeLibTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='recipe_library'")->fetchAll();
|
||||
if (empty($recipeLibTables)) {
|
||||
$db->exec("
|
||||
CREATE TABLE recipe_library (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
recipe_json TEXT NOT NULL,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
");
|
||||
}
|
||||
// Recipe tags (v2.3) — tags pour trier/filtrer "Mes recettes", gérables depuis Config
|
||||
$recipeTagsTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='recipe_tags'")->fetchAll();
|
||||
if (empty($recipeTagsTables)) {
|
||||
$db->exec("
|
||||
CREATE TABLE recipe_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
icon TEXT DEFAULT '🏷️',
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
");
|
||||
$db->exec("INSERT INTO recipe_tags (key, label, icon, sort_order) VALUES
|
||||
('cocktail', 'Cocktail', '🍹', 1),
|
||||
('sans_alcool', 'Sans alcool', '🚫', 2),
|
||||
('shot', 'Shot', '🥃', 3),
|
||||
('long_drink', 'Long drink', '🍺', 4),
|
||||
('aperitif', 'Apéritif', '🍾', 5),
|
||||
('digestif', 'Digestif', '❄️', 6),
|
||||
('rhum', 'Rhum', '🥃', 7),
|
||||
('vodka', 'Vodka', '🍸', 8),
|
||||
('gin', 'Gin', '🌿', 9),
|
||||
('whisky', 'Whisky', '🥃', 10),
|
||||
('tequila', 'Tequila', '🌵', 11),
|
||||
('vin', 'Vin', '🍷', 12),
|
||||
('champagne', 'Champagne / Mousseux', '🍾', 13),
|
||||
('acidule', 'Acidulé', '🍋', 14),
|
||||
('sucre', 'Sucré', '🍬', 15),
|
||||
('amer', 'Amer', '☕', 16),
|
||||
('epice', 'Épicé', '🌶️', 17),
|
||||
('fruite', 'Fruité', '🍊', 18),
|
||||
('herbace', 'Herbacé', '🌿', 19),
|
||||
('ete', 'Été', '☀️', 20),
|
||||
('hiver', 'Hiver', '❄️', 21),
|
||||
('soiree', 'Soirée', '🎉', 22),
|
||||
('brunch', 'Brunch', '🥂', 23)
|
||||
");
|
||||
}
|
||||
// Migration: add keywords column to recipe_tags if missing
|
||||
$rtCols = array_column($db->query("PRAGMA table_info(recipe_tags)")->fetchAll(), 'name');
|
||||
if (!in_array('keywords', $rtCols)) {
|
||||
try { $db->exec("ALTER TABLE recipe_tags ADD COLUMN keywords TEXT DEFAULT ''"); }
|
||||
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||
}
|
||||
|
||||
// Custom quantity units (v2.4) — unités personnalisées (ex: kg, L) avec facteur de conversion vers pz/g/ml, gérables depuis Config
|
||||
$customUnitsTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='custom_units'")->fetchAll();
|
||||
if (empty($customUnitsTables)) {
|
||||
$db->exec("
|
||||
CREATE TABLE custom_units (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
icon TEXT DEFAULT '📏',
|
||||
base_unit TEXT NOT NULL DEFAULT 'g',
|
||||
factor REAL NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
");
|
||||
}
|
||||
// Add display_unit_key column to products if missing — unité personnalisée a afficher pour ce produit
|
||||
$prodColsUnits = array_column($db->query("PRAGMA table_info(products)")->fetchAll(), 'name');
|
||||
if (!in_array('display_unit_key', $prodColsUnits)) {
|
||||
try { $db->exec("ALTER TABLE products ADD COLUMN display_unit_key TEXT DEFAULT NULL"); }
|
||||
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||
}
|
||||
// Internal shopping list table (v1.8.0) — used when SHOPPING_MODE=internal
|
||||
|
||||
// Internal shopping list table (v1.8.0) — used when SHOPPING_MODE=internal
|
||||
$shopTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='shopping_list'")->fetchAll();
|
||||
if (empty($shopTables)) {
|
||||
$db->exec("
|
||||
CREATE TABLE shopping_list (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
raw_name TEXT NOT NULL DEFAULT '',
|
||||
specification TEXT NOT NULL DEFAULT '',
|
||||
added_at INTEGER DEFAULT (strftime('%s','now')),
|
||||
sort_order INTEGER DEFAULT 0
|
||||
)
|
||||
");
|
||||
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_list_name ON shopping_list(lower(name))");
|
||||
}
|
||||
|
||||
// Add is_favorite column to recipes if missing (#124)
|
||||
$recCols = array_column($db->query("PRAGMA table_info(recipes)")->fetchAll(), 'name');
|
||||
if (!in_array('is_favorite', $recCols)) {
|
||||
try { $db->exec("ALTER TABLE recipes ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0"); }
|
||||
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||
}
|
||||
|
||||
// Add nutriments_json column to products if missing (#118)
|
||||
$prodCols2 = array_column($db->query("PRAGMA table_info(products)")->fetchAll(), 'name');
|
||||
if (!in_array('nutriments_json', $prodCols2)) {
|
||||
try { $db->exec("ALTER TABLE products ADD COLUMN nutriments_json TEXT DEFAULT NULL"); }
|
||||
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||
}
|
||||
|
||||
// Add tags column to products if missing (bugfix: insert referenced a column never created)
|
||||
if (!in_array('tags', $prodCols2)) {
|
||||
try { $db->exec("ALTER TABLE products ADD COLUMN tags TEXT DEFAULT NULL"); }
|
||||
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -703,10 +360,6 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 2;
|
||||
if (preg_match('/\blatte\b/', $n)) return 1;
|
||||
if (preg_match('/\bformaggio\b/', $n)) return 2;
|
||||
// Root vegetables / tubers in pantry: sfusi in un sacchetto, durano 3-5 settimane
|
||||
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 30;
|
||||
if (preg_match('/\b(cipolla|cipolle|aglio|scalogno|porro)\b/', $n)) return 30;
|
||||
if (preg_match('/\b(carota|carote)\b/', $n)) return 14;
|
||||
return 60; // generic pantry fallback
|
||||
}
|
||||
|
||||
@@ -719,10 +372,8 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 5;
|
||||
if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 3;
|
||||
if (preg_match('/philadelphia|spalmabile/', $n)) return 7;
|
||||
// Specific hard cheeses that contain 'fresco' in their commercial name (e.g. Asiago fresco)
|
||||
// must be matched BEFORE the generic 'formaggio fresco' catch-all
|
||||
if (preg_match('/parmigiano|grana|pecorino|provolone|asiago|fontina|emmental|gruyere|scamorza|groviera/', $n)) return 28;
|
||||
if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5;
|
||||
if (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) return 21;
|
||||
if (preg_match('/formaggio/', $n)) return 10;
|
||||
if (preg_match('/\bburro\b/', $n)) return 30;
|
||||
if (preg_match('/\bpanna\b/', $n)) return 4;
|
||||
@@ -731,7 +382,6 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\b(pollo|tacchino|maiale|manzo|vitello|agnello)\b/', $n)) return 2;
|
||||
if (preg_match('/salmone|tonno\s+fresco|pesce(?!\s+in)/', $n)) return 2;
|
||||
if (preg_match('/\b(passata|pelati|polpa|sugo|salsa\s+di\s+pomodoro)\b/', $n)) return 5;
|
||||
if (preg_match('/insalata\s+di\s+(riso|pasta|farro|orzo|couscous)/', $n)) return 7;
|
||||
if (preg_match('/insalata|rucola|spinaci|lattuga|crescione|germogli/', $n)) return 4;
|
||||
if (preg_match('/\b(succo|spremuta)\b/', $n)) return 3;
|
||||
if (preg_match('/\b(birra|beer)\b/', $n)) return 3;
|
||||
@@ -753,21 +403,13 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 4;
|
||||
if (preg_match('/\baglio\b/', $n)) return 14;
|
||||
|
||||
// ── F.extra: Bread in fridge (opened) ──────────────────────────────────
|
||||
// Thin flatbreads (piadina, crescia, tigella) get mold very quickly
|
||||
if (preg_match('/\b(piadina|piadelle?|crescia|tigella)\b/', $n)) return 2;
|
||||
// Packaged sliced bread — preservatives help a bit
|
||||
if (preg_match('/\b(bauletto|pancarrè|pan\s+carr|tramezzin)\b/', $n)) return 4;
|
||||
// Generic bread / sandwich bread in fridge
|
||||
if (preg_match('/\bpane\b/', $cat)) return 3;
|
||||
|
||||
// ── G: Fridge condiments — medium shelf-life ─────────────────────────
|
||||
if (preg_match('/maionese|mayo|mayon/', $n)) return 90;
|
||||
if (preg_match('/\bketchup\b/', $n)) return 90;
|
||||
if (preg_match('/\b(senape|mustard)\b/', $n)) return 90;
|
||||
if (preg_match('/salsa\s+di\s+soia|soy\s*sauce/', $n)) return 90;
|
||||
if (preg_match('/\b(tabasco|worcestershire|sriracha)\b/', $n)) return 180;
|
||||
if (preg_match('/confettura|marmellata/', $n)) return 180;
|
||||
if (preg_match('/confettura|marmellata/', $n)) return 60;
|
||||
if (preg_match('/nutella|cioccolat/', $n)) return 60;
|
||||
|
||||
// ── H: Category fallbacks ────────────────────────────────────────────
|
||||
@@ -800,7 +442,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
elseif (preg_match('/yogurt/', $n)) $days = 21;
|
||||
elseif (preg_match('/mozzarella|burrata|stracciatella/', $n)) $days = 5;
|
||||
elseif (preg_match('/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) $days = 10;
|
||||
elseif (preg_match('/parmigiano|grana|pecorino|provolone|asiago|fontina|emmental|gruyere|scamorza|groviera/', $n)) $days = 60;
|
||||
elseif (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) $days = 60;
|
||||
elseif (preg_match('/burro/', $n)) $days = 60;
|
||||
elseif (preg_match('/panna/', $n)) $days = 14;
|
||||
elseif (preg_match('/prosciutto\s+cotto|mortadella|wurstel/', $n)) $days = 7;
|
||||
@@ -809,7 +451,6 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
elseif (preg_match('/uova/', $n)) $days = 28;
|
||||
elseif (preg_match('/pane\s+fresco|pane\s+in\s+cassetta/', $n)) $days = 5;
|
||||
elseif (preg_match('/pane\s+confezionato|pan\s+carr|pancarrè/', $n)) $days = 14;
|
||||
elseif (preg_match('/insalata\s+di\s+(riso|pasta|farro|orzo|couscous)/', $n)) $days = 7;
|
||||
elseif (preg_match('/insalata|rucola|spinaci\s+freschi/', $n)) $days = 5;
|
||||
elseif (preg_match('/pollo|tacchino|maiale|manzo|vitello|sovracosci|cosci/', $n)) $days = 3;
|
||||
elseif (preg_match('/salmone|tonno\s+fresco|pesce/', $n) && !preg_match('/tonno\s+in\s+scatola|tonno\s+rio/', $n)) $days = 2;
|
||||
@@ -826,7 +467,7 @@ function estimateSealedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
elseif (preg_match('/carota|carote|zucchina|zucchine|peperoni|melanzane/', $n)) $days = 7;
|
||||
elseif (preg_match('/broccoli|cavolfiore|cavolo|spinaci|bietola/', $n)) $days = 5;
|
||||
elseif (preg_match('/cipolla|cipolle/', $n)) $days = 10;
|
||||
elseif (preg_match('/patata|patate/', $n)) $days = 30; // whole tubers in a bag, pantry: 3-5 weeks
|
||||
elseif (preg_match('/patata|patate/', $n)) $days = 14;
|
||||
elseif (preg_match('/biscott|cracker|grissini|fette\s+biscott/', $n)) $days = 180;
|
||||
elseif (preg_match('/nutella|marmellata|miele/', $n)) $days = 365;
|
||||
elseif (preg_match('/passata|pelati|pomodor/', $n)) $days = 730;
|
||||
@@ -906,4 +547,4 @@ function getVacuumExpiryDaysPHP(int $baseDays): int {
|
||||
if ($baseDays <= 30) return (int)round($baseDays * 2.5);
|
||||
if ($baseDays <= 90) return (int)round($baseDays * 2.5);
|
||||
return (int)round($baseDays * 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf — shared path constants.
|
||||
*/
|
||||
|
||||
define('EVERSHELF_ROOT', dirname(__DIR__, 2));
|
||||
define('GH_REPO', 'dadaloop82/EverShelf');
|
||||
define('PRICE_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_price_cache.json');
|
||||
define('CATEGORY_CACHE_PATH', EVERSHELF_ROOT . '/data/category_ai_cache.json');
|
||||
define('SHELF_CACHE_PATH', EVERSHELF_ROOT . '/data/opened_shelf_cache.json');
|
||||
define('FOODFACTS_CACHE_PATH', EVERSHELF_ROOT . '/data/food_facts_cache.json');
|
||||
define('SHOPPING_NAME_CACHE_PATH', EVERSHELF_ROOT . '/data/shopping_name_cache.json');
|
||||
define('BRING_TOKEN_PATH', EVERSHELF_ROOT . '/data/bring_token.json');
|
||||
define('AI_USAGE_PATH', EVERSHELF_ROOT . '/data/ai_usage.json');
|
||||
define('BACKUP_DIR', EVERSHELF_ROOT . '/data/backups');
|
||||
define('BACKUP_LAST_TS_PATH', EVERSHELF_ROOT . '/data/backup_last_ts.json');
|
||||
define('CRON_LOG_PATH', EVERSHELF_ROOT . '/data/cron.log');
|
||||
|
||||
define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15));
|
||||
define('GEMINI_COST_25F_OUT', (float)(getenv('GEMINI_COST_25F_OUT') ?: 0.60));
|
||||
define('GEMINI_COST_20F_IN', (float)(getenv('GEMINI_COST_20F_IN') ?: 0.10));
|
||||
define('GEMINI_COST_20F_OUT', (float)(getenv('GEMINI_COST_20F_OUT') ?: 0.40));
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Rotate data/cron.log — keep last N MB / lines.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/constants.php';
|
||||
|
||||
function evershelfRotateCronLog(?int $maxBytes = null, int $keepRotated = 3): void {
|
||||
$path = CRON_LOG_PATH;
|
||||
if (!file_exists($path)) {
|
||||
return;
|
||||
}
|
||||
$maxBytes = $maxBytes ?? max(65536, (int)env('CRON_LOG_MAX_BYTES', '524288'));
|
||||
$size = filesize($path);
|
||||
if ($size === false || $size <= $maxBytes) {
|
||||
return;
|
||||
}
|
||||
for ($i = $keepRotated; $i >= 1; $i--) {
|
||||
$from = ($i === 1) ? $path : $path . '.' . ($i - 1);
|
||||
$to = $path . '.' . $i;
|
||||
if ($i === $keepRotated && file_exists($to)) {
|
||||
@unlink($to);
|
||||
}
|
||||
if (file_exists($from)) {
|
||||
@rename($from, $to);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf — environment variable loader (.env).
|
||||
*/
|
||||
function loadEnv(): array {
|
||||
static $cache = null;
|
||||
if ($cache !== null) {
|
||||
return $cache;
|
||||
}
|
||||
$envFile = dirname(__DIR__, 2) . '/.env';
|
||||
$cache = [];
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '#') === 0 || strpos($line, '=') === false) {
|
||||
continue;
|
||||
}
|
||||
[$key, $val] = explode('=', $line, 2);
|
||||
$cache[trim($key)] = trim($val);
|
||||
}
|
||||
}
|
||||
return $cache;
|
||||
}
|
||||
function env(string $key, string $default = ''): string {
|
||||
$vars = loadEnv();
|
||||
if (isset($vars[$key]) && $vars[$key] !== '') {
|
||||
return $vars[$key];
|
||||
}
|
||||
// Fallback to system/Docker environment variables (e.g. set via Portainer)
|
||||
$sysVal = getenv($key);
|
||||
if ($sysVal !== false && $sysVal !== '') {
|
||||
return $sysVal;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
/** Push a single key into the in-memory env cache (after .env write). */
|
||||
function envCacheSet(string $key, string $value): void {
|
||||
loadEnv();
|
||||
// Force reload on next call — callers should use loadEnv() return for batch updates
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf — GitHub issue reporting token (encrypted at rest in .env).
|
||||
*
|
||||
* Configure ONE of:
|
||||
* GH_ISSUE_TOKEN=ghp_... (plain, .env is gitignored)
|
||||
* GH_ISSUE_TOKEN_ENC=... + GH_ISSUE_TOKEN_KEY=... (AES-256-GCM, preferred)
|
||||
*
|
||||
* Generate encrypted value: php scripts/encrypt-gh-token.php 'ghp_xxx' 'your-secret-key'
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/env.php';
|
||||
|
||||
function evershelfDecryptGhToken(string $encB64, string $key): string {
|
||||
$raw = base64_decode($encB64, true);
|
||||
if ($raw === false || strlen($raw) < 28) {
|
||||
return '';
|
||||
}
|
||||
$iv = substr($raw, 0, 12);
|
||||
$tag = substr($raw, 12, 16);
|
||||
$cipher = substr($raw, 28);
|
||||
$plain = openssl_decrypt(
|
||||
$cipher,
|
||||
'aes-256-gcm',
|
||||
hash('sha256', $key, true),
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
return ($plain !== false) ? $plain : '';
|
||||
}
|
||||
|
||||
function evershelfEncryptGhToken(string $plain, string $key): string {
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$cipher = openssl_encrypt(
|
||||
$plain,
|
||||
'aes-256-gcm',
|
||||
hash('sha256', $key, true),
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
return base64_encode($iv . $tag . $cipher);
|
||||
}
|
||||
|
||||
/** Decode GitHub Issues token at runtime — never stored in source code. */
|
||||
function _ghToken(): string {
|
||||
static $token = null;
|
||||
if ($token !== null) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$plain = env('GH_ISSUE_TOKEN');
|
||||
if ($plain !== '') {
|
||||
$token = $plain;
|
||||
return $token;
|
||||
}
|
||||
|
||||
$enc = env('GH_ISSUE_TOKEN_ENC');
|
||||
$key = env('GH_ISSUE_TOKEN_KEY');
|
||||
if ($enc !== '' && $key !== '') {
|
||||
$token = evershelfDecryptGhToken($enc, $key);
|
||||
return $token;
|
||||
}
|
||||
|
||||
$token = '';
|
||||
return $token;
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf — authentication, CORS, demo mode, scale gateway allowlist.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/env.php';
|
||||
|
||||
/** Effective API token: API_TOKEN takes precedence over legacy SETTINGS_TOKEN. */
|
||||
function evershelfEffectiveApiToken(): string {
|
||||
$api = env('API_TOKEN');
|
||||
if ($api !== '') {
|
||||
return $api;
|
||||
}
|
||||
return env('SETTINGS_TOKEN', '');
|
||||
}
|
||||
|
||||
function evershelfApiTokenRequired(): bool {
|
||||
return evershelfEffectiveApiToken() !== '';
|
||||
}
|
||||
|
||||
function evershelfGetProvidedApiToken(): string {
|
||||
if (!empty($_SERVER['HTTP_X_API_TOKEN'])) {
|
||||
return (string)$_SERVER['HTTP_X_API_TOKEN'];
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_SETTINGS_TOKEN'])) {
|
||||
return (string)$_SERVER['HTTP_X_SETTINGS_TOKEN'];
|
||||
}
|
||||
if (isset($_GET['api_token'])) {
|
||||
return (string)$_GET['api_token'];
|
||||
}
|
||||
// Home Assistant ha-evershelf sends Authorization: Bearer (legacy)
|
||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
|
||||
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
||||
?? '';
|
||||
if (preg_match('/^Bearer\s+(\S+)/i', $authHeader, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return evershelfGetProvidedApiTokenFromHeaders();
|
||||
}
|
||||
|
||||
function evershelfApiTokenValid(): bool {
|
||||
$required = evershelfEffectiveApiToken();
|
||||
if ($required === '') {
|
||||
return true;
|
||||
}
|
||||
$provided = evershelfGetProvidedApiToken();
|
||||
return $provided !== '' && hash_equals($required, $provided);
|
||||
}
|
||||
|
||||
function evershelfGetProvidedApiTokenFromHeaders(): string {
|
||||
return (string)($_SERVER['HTTP_X_API_TOKEN'] ?? $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '');
|
||||
}
|
||||
|
||||
/** Actions reachable without API token (telemetry + public probes). */
|
||||
function evershelfPublicActions(): array {
|
||||
return [
|
||||
'ping',
|
||||
'app_bootstrap',
|
||||
'check_update',
|
||||
'report_error',
|
||||
'report_bug',
|
||||
'client_log',
|
||||
'gdrive_oauth_callback',
|
||||
];
|
||||
}
|
||||
|
||||
/** GET actions that mutate state — require auth when token is configured. */
|
||||
function evershelfMutatingGetActions(): array {
|
||||
return ['db_cleanup', 'export_inventory'];
|
||||
}
|
||||
|
||||
function evershelfDestructiveActions(): array {
|
||||
return [
|
||||
'save_settings', 'db_cleanup',
|
||||
'backup_now', 'backup_delete', 'backup_restore',
|
||||
'gdrive_push', 'gdrive_oauth_exchange',
|
||||
'migrate_units',
|
||||
];
|
||||
}
|
||||
|
||||
function evershelfActionNeedsAuth(string $action, string $method): bool {
|
||||
if (!evershelfApiTokenRequired()) {
|
||||
return false;
|
||||
}
|
||||
if (in_array($action, evershelfPublicActions(), true)) {
|
||||
return false;
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
return true;
|
||||
}
|
||||
if ($method === 'GET' && in_array($action, evershelfMutatingGetActions(), true)) {
|
||||
return true;
|
||||
}
|
||||
if (in_array($action, ['get_logs', 'gemini_usage', 'get_client_log'], true)) {
|
||||
return true;
|
||||
}
|
||||
if (in_array($action, evershelfDestructiveActions(), true)) {
|
||||
return true;
|
||||
}
|
||||
// Protect all data reads when API token is set
|
||||
return true;
|
||||
}
|
||||
|
||||
function evershelfRequireApiAuth(string $action, string $method): void {
|
||||
if (!evershelfActionNeedsAuth($action, $method)) {
|
||||
return;
|
||||
}
|
||||
if (evershelfApiTokenValid()) {
|
||||
return;
|
||||
}
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'unauthorized',
|
||||
'api_token_required' => true,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
function evershelfRequireAuthForSensitive(string $action): void {
|
||||
if (!evershelfApiTokenRequired()) {
|
||||
return;
|
||||
}
|
||||
if (evershelfApiTokenValid()) {
|
||||
return;
|
||||
}
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['success' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
function evershelfSendCorsHeaders(): void {
|
||||
$configured = env('CORS_ORIGIN', '');
|
||||
if ($configured === '') {
|
||||
// Same-origin SPA — do not emit wildcard CORS
|
||||
return;
|
||||
}
|
||||
if ($configured === '*') {
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
} else {
|
||||
$reqOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed = array_filter(array_map('trim', explode(',', $configured)));
|
||||
if ($reqOrigin !== '' && in_array($reqOrigin, $allowed, true)) {
|
||||
header('Access-Control-Allow-Origin: ' . $reqOrigin);
|
||||
header('Vary: Origin');
|
||||
}
|
||||
}
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, X-EverShelf-Request, X-API-Token, X-Settings-Token');
|
||||
}
|
||||
|
||||
/** Read-only actions allowed in DEMO_MODE. */
|
||||
function evershelfDemoReadOnlyActions(): array {
|
||||
return [
|
||||
'ping', 'check_update', 'health_check', 'get_settings', 'gemini_usage',
|
||||
'search_barcode', 'lookup_barcode', 'resolve_barcode', 'stock_for_name',
|
||||
'product_get', 'products_list', 'products_search', 'inventory_search', 'ai_product_suggest',
|
||||
'inventory_list', 'inventory_summary', 'inventory_finished_items',
|
||||
'transactions_list', 'stats', 'monthly_stats', 'macro_stats',
|
||||
'consumption_predictions', 'inventory_anomalies', 'inventory_duplicate_loss_checks',
|
||||
'recent_popular_products', 'expiry_history', 'food_facts', 'opened_shelf_life',
|
||||
'bring_list', 'bring_suggest', 'shopping_list', 'shopping_suggest', 'smart_shopping',
|
||||
'recipes_list', 'chat_list', 'app_settings_get',
|
||||
'ha_sensor', 'ha_info', 'ha_shopping_items', 'ha_test', 'ha_calendar',
|
||||
'guess_category', 'get_shopping_price', 'get_all_shopping_prices',
|
||||
'backup_list', 'export_inventory',
|
||||
];
|
||||
}
|
||||
|
||||
function evershelfDemoBlocksAction(string $action, string $method): bool {
|
||||
if (env('DEMO_MODE') !== 'true') {
|
||||
return false;
|
||||
}
|
||||
if (in_array($action, evershelfDemoReadOnlyActions(), true)) {
|
||||
return false;
|
||||
}
|
||||
// Block all AI generation in demo (cost + writes)
|
||||
if (str_starts_with($action, 'gemini_') || in_array($action, [
|
||||
'generate_recipe', 'generate_recipe_stream', 'chat_to_recipe', 'recipe_from_ingredient',
|
||||
], true)) {
|
||||
return true;
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
return true;
|
||||
}
|
||||
if (in_array($action, evershelfMutatingGetActions(), true)) {
|
||||
return true;
|
||||
}
|
||||
return !in_array($action, evershelfDemoReadOnlyActions(), true);
|
||||
}
|
||||
|
||||
/** Hosts allowed for scale WebSocket relay (SSRF guard). */
|
||||
function evershelfAllowedScaleHosts(): array {
|
||||
$hosts = ['127.0.0.1', 'localhost', '::1'];
|
||||
$gw = env('SCALE_GATEWAY_URL', '');
|
||||
if ($gw !== '') {
|
||||
$p = parse_url($gw);
|
||||
if (!empty($p['host'])) {
|
||||
$hosts[] = strtolower($p['host']);
|
||||
}
|
||||
}
|
||||
// Server's own LAN IP — gateway may bind here on kiosk LAN
|
||||
if (function_exists('gethostname')) {
|
||||
$lan = gethostbyname(gethostname());
|
||||
if ($lan && filter_var($lan, FILTER_VALIDATE_IP)) {
|
||||
$hosts[] = $lan;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($hosts));
|
||||
}
|
||||
|
||||
function evershelfScaleHostAllowed(string $host): bool {
|
||||
$host = strtolower(trim($host));
|
||||
if ($host === '') {
|
||||
return false;
|
||||
}
|
||||
foreach (evershelfAllowedScaleHosts() as $allowed) {
|
||||
if ($host === strtolower($allowed)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Allow private /24 only when host matches server's subnet (kiosk on same LAN)
|
||||
$serverIp = evershelfLocalLanIp();
|
||||
if ($serverIp !== '') {
|
||||
$subnet = implode('.', array_slice(explode('.', $serverIp), 0, 3));
|
||||
if (str_starts_with($host, $subnet . '.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function evershelfLocalLanIp(): 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;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the request comes from the EverShelf web UI on the same host.
|
||||
* Used to auto-provision API_TOKEN to the browser without manual .env copy.
|
||||
*/
|
||||
function evershelfIsSameOriginBrowser(): bool {
|
||||
$host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
|
||||
if ($host === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if ($origin !== '') {
|
||||
$oh = parse_url($origin, PHP_URL_HOST);
|
||||
return $oh && strtolower($oh) === $host;
|
||||
}
|
||||
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||
if ($referer !== '') {
|
||||
$rh = parse_url($referer, PHP_URL_HOST);
|
||||
return $rh && strtolower($rh) === $host;
|
||||
}
|
||||
|
||||
$fetchSite = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? '';
|
||||
if (in_array($fetchSite, ['same-origin', 'same-site'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Auth for scale endpoints — EventSource cannot send headers; allow query token or same-origin UI. */
|
||||
function evershelfRequireScaleAccess(): void {
|
||||
if (!evershelfApiTokenRequired()) {
|
||||
return;
|
||||
}
|
||||
if (evershelfApiTokenValid()) {
|
||||
return;
|
||||
}
|
||||
if (evershelfIsSameOriginBrowser()) {
|
||||
return;
|
||||
}
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf Logger — rotating file logger with 4 configurable levels.
|
||||
*
|
||||
* Levels (in order of verbosity):
|
||||
* DEBUG(0) — ogni minima operazione: query, cache, AI payload, function entry/exit
|
||||
* INFO (1) — azioni completate, AI result summary, sync status [default]
|
||||
* WARN (2) — rate limit, cache miss, AI fallback, token renewal, slow op
|
||||
* ERROR(3) — DB failure, AI API error, file write error, exception
|
||||
*
|
||||
* Config via .env (all optional):
|
||||
* LOG_LEVEL = INFO (DEBUG|INFO|WARN|ERROR)
|
||||
* LOG_ROTATE_HOURS = 24 (new file every N hours; 1–168; default 24)
|
||||
* LOG_MAX_FILES = 14 (max rotated files to keep; default 14)
|
||||
*
|
||||
* Log files: logs/evershelf_YYYY-MM-DD_HH.log
|
||||
* Each line: [2026-05-18 14:23:11] [INFO ] [rid=a1b2c3d4] [action] Message {ctx}
|
||||
*/
|
||||
class EverLog {
|
||||
|
||||
// ── Level constants ────────────────────────────────────────────────────
|
||||
const DEBUG = 0;
|
||||
const INFO = 1;
|
||||
const WARN = 2;
|
||||
const ERROR = 3;
|
||||
|
||||
private static bool $initialized = false;
|
||||
private static int $level = self::INFO;
|
||||
private static string $logFile = '';
|
||||
private static string $logDir = '';
|
||||
private static int $rotateHours = 24;
|
||||
private static int $maxFiles = 14;
|
||||
private static string $requestId = '';
|
||||
private static string $currentAction = '-';
|
||||
|
||||
// ── Init (called lazily on first write) ────────────────────────────────
|
||||
private static function init(): void {
|
||||
if (self::$initialized) return;
|
||||
self::$initialized = true;
|
||||
|
||||
// Read .env values via getenv() (populated by Apache SetEnv or putenv() in index.php)
|
||||
$envLevel = strtoupper((string)(getenv('LOG_LEVEL') ?: 'INFO'));
|
||||
$rotateHours = max(1, min(168, (int)(getenv('LOG_ROTATE_HOURS') ?: 24)));
|
||||
$maxFiles = max(1, min(365, (int)(getenv('LOG_MAX_FILES') ?: 14)));
|
||||
|
||||
self::$level = match($envLevel) {
|
||||
'DEBUG' => self::DEBUG,
|
||||
'WARN' => self::WARN,
|
||||
'ERROR' => self::ERROR,
|
||||
default => self::INFO,
|
||||
};
|
||||
self::$rotateHours = $rotateHours;
|
||||
self::$maxFiles = $maxFiles;
|
||||
self::$requestId = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
|
||||
// Ensure log directory exists
|
||||
$base = dirname(__DIR__) . '/logs';
|
||||
self::$logDir = $base;
|
||||
if (!is_dir($base)) {
|
||||
@mkdir($base, 0755, true);
|
||||
}
|
||||
|
||||
// Compute current log file path (slot by rotate-hours bucket)
|
||||
$slotTs = (int)(floor(time() / ($rotateHours * 3600)) * ($rotateHours * 3600));
|
||||
$slotLabel = gmdate('Y-m-d_H', $slotTs);
|
||||
self::$logFile = "$base/evershelf_{$slotLabel}.log";
|
||||
|
||||
// Rotate (delete oldest files beyond max)
|
||||
self::rotate();
|
||||
}
|
||||
|
||||
// ── Rotate old log files ───────────────────────────────────────────────
|
||||
private static function rotate(): void {
|
||||
$files = glob(self::$logDir . '/evershelf_*.log') ?: [];
|
||||
if (count($files) <= self::$maxFiles) return;
|
||||
sort($files); // oldest first (filenames are lexicographically sortable by date)
|
||||
$toDelete = array_slice($files, 0, count($files) - self::$maxFiles);
|
||||
foreach ($toDelete as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core write ────────────────────────────────────────────────────────
|
||||
private static function write(int $lvl, string $msg, array $ctx, string $action): void {
|
||||
self::init();
|
||||
if ($lvl < self::$level) return;
|
||||
|
||||
$labels = ['DEBUG', 'INFO ', 'WARN ', 'ERROR'];
|
||||
$ts = gmdate('Y-m-d H:i:s');
|
||||
$act = $action !== '-' ? $action : self::$currentAction;
|
||||
$ctxStr = empty($ctx) ? '' : ' ' . json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$line = "[{$ts}] [{$labels[$lvl]}] [rid=" . self::$requestId . "] [{$act}] {$msg}{$ctxStr}\n";
|
||||
|
||||
@file_put_contents(self::$logFile, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
/** Set the current action name (shown in every subsequent log line for this request). */
|
||||
public static function setAction(string $action): void {
|
||||
self::$currentAction = $action;
|
||||
}
|
||||
|
||||
/** Log at DEBUG level — every minor operation, query, cache hit/miss, AI payload. */
|
||||
public static function debug(string $msg, array $ctx = [], string $action = '-'): void {
|
||||
self::write(self::DEBUG, $msg, $ctx, $action);
|
||||
}
|
||||
|
||||
/** Log at INFO level — action completed, recipe generated, sync done. */
|
||||
public static function info(string $msg, array $ctx = [], string $action = '-'): void {
|
||||
self::write(self::INFO, $msg, $ctx, $action);
|
||||
}
|
||||
|
||||
/** Log at WARN level — rate limit, AI fallback, slow op, token renewal. */
|
||||
public static function warn(string $msg, array $ctx = [], string $action = '-'): void {
|
||||
self::write(self::WARN, $msg, $ctx, $action);
|
||||
}
|
||||
|
||||
/** Log at ERROR level — DB failure, AI API error, file write error, exception. */
|
||||
public static function error(string $msg, array $ctx = [], string $action = '-'): void {
|
||||
self::write(self::ERROR, $msg, $ctx, $action);
|
||||
}
|
||||
|
||||
/** Convenience: log a Throwable at ERROR level with class + location. */
|
||||
public static function exception(\Throwable $e, string $action = '-', array $extra = []): void {
|
||||
self::write(self::ERROR, $e->getMessage(), array_merge([
|
||||
'class' => get_class($e),
|
||||
'at' => basename($e->getFile()) . ':' . $e->getLine(),
|
||||
'trace' => substr($e->getTraceAsString(), 0, 800),
|
||||
], $extra), $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the start of an action request (INFO).
|
||||
* Automatically sets the current action name so subsequent lines inherit it.
|
||||
*/
|
||||
public static function request(string $action, string $method, array $params = []): void {
|
||||
self::setAction($action);
|
||||
// At DEBUG: include all params; at INFO just the action+method
|
||||
if (self::$level <= self::DEBUG) {
|
||||
self::write(self::DEBUG, "→ {$method} /{$action}", $params, $action);
|
||||
} else {
|
||||
self::write(self::INFO, "→ {$method} /{$action}", [], $action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a DB query at DEBUG level.
|
||||
* @param string $sql Truncated SQL or a descriptive label
|
||||
* @param mixed $result Number of rows affected/returned (optional)
|
||||
* @param float $elapsed Execution time in seconds (optional)
|
||||
*/
|
||||
public static function query(string $sql, $result = null, float $elapsed = 0.0): void {
|
||||
if (self::$level > self::DEBUG) return; // skip entirely unless DEBUG
|
||||
$ctx = [];
|
||||
if ($result !== null) $ctx['rows'] = $result;
|
||||
if ($elapsed > 0) $ctx['ms'] = round($elapsed * 1000, 1);
|
||||
if ($elapsed > 1.0) $ctx['SLOW'] = true; // highlight slow queries even in context
|
||||
self::write(self::DEBUG, 'DB: ' . substr($sql, 0, 200), $ctx, self::$currentAction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a slow operation as WARN regardless of configured level.
|
||||
* Call this after any operation that took more than $thresholdSec.
|
||||
*/
|
||||
public static function slowOp(string $label, float $elapsed, float $thresholdSec = 2.0): void {
|
||||
if ($elapsed < $thresholdSec) return;
|
||||
self::write(self::WARN, "SLOW_OP: {$label}", ['elapsed_s' => round($elapsed, 2)], self::$currentAction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an AI call at INFO level (or DEBUG for full payload).
|
||||
* @param string $model Model name (e.g. 'gemini-2.5-flash')
|
||||
* @param int $promptLen Character length of the prompt
|
||||
* @param bool $isFallback Whether this is the fallback model
|
||||
*/
|
||||
public static function aiCall(string $model, int $promptLen, bool $isFallback = false): void {
|
||||
$ctx = ['model' => $model, 'prompt_chars' => $promptLen];
|
||||
if ($isFallback) $ctx['fallback'] = true;
|
||||
$level = $isFallback ? self::WARN : self::INFO;
|
||||
self::write($level, 'AI call', $ctx, self::$currentAction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an AI response at INFO level.
|
||||
* @param string $model Model that responded
|
||||
* @param int $outputLen Character length of output
|
||||
* @param float $elapsed Call duration in seconds
|
||||
* @param bool $ok Whether the call succeeded
|
||||
* @param string $errorMsg Error message if not ok
|
||||
*/
|
||||
public static function aiResponse(string $model, int $outputLen, float $elapsed, bool $ok = true, string $errorMsg = ''): void {
|
||||
$ctx = ['model' => $model, 'output_chars' => $outputLen, 'elapsed_s' => round($elapsed, 2)];
|
||||
if (!$ok) {
|
||||
$ctx['error'] = substr($errorMsg, 0, 200);
|
||||
self::write(self::ERROR, 'AI error', $ctx, self::$currentAction);
|
||||
} else {
|
||||
self::write(self::INFO, 'AI ok', $ctx, self::$currentAction);
|
||||
}
|
||||
// Warn if over 10s
|
||||
if ($ok && $elapsed > 10.0) {
|
||||
self::write(self::WARN, 'AI response slow', ['elapsed_s' => round($elapsed, 2)], self::$currentAction);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a cache event at DEBUG level.
|
||||
* @param string $cacheKey The cache key (or a label)
|
||||
* @param bool $hit true = cache hit, false = cache miss
|
||||
* @param string $cacheType 'file', 'session', 'memory'
|
||||
*/
|
||||
public static function cache(string $cacheKey, bool $hit, string $cacheType = 'file'): void {
|
||||
if (self::$level > self::DEBUG) return;
|
||||
self::write(self::DEBUG,
|
||||
($hit ? 'CACHE HIT' : 'CACHE MISS') . " [{$cacheType}]",
|
||||
['key' => substr($cacheKey, 0, 64)],
|
||||
self::$currentAction
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the last $lines log lines from all available log files, newest last.
|
||||
* Used by the get_logs API endpoint.
|
||||
*/
|
||||
public static function tail(int $lines = 500): array {
|
||||
self::init();
|
||||
$files = glob(self::$logDir . '/evershelf_*.log') ?: [];
|
||||
if (empty($files)) return [];
|
||||
rsort($files); // newest file first
|
||||
|
||||
$collected = [];
|
||||
foreach ($files as $f) {
|
||||
if (count($collected) >= $lines) break;
|
||||
$content = @file_get_contents($f);
|
||||
if ($content === false) continue;
|
||||
$fLines = array_filter(explode("\n", $content));
|
||||
// Prepend so we read newest-first → older lines at front
|
||||
$collected = array_merge(array_values($fLines), $collected);
|
||||
}
|
||||
// Return last $lines, newest at end (chronological order)
|
||||
return array_values(array_slice($collected, -$lines));
|
||||
}
|
||||
|
||||
/** List available log files with their sizes and date ranges. */
|
||||
public static function listFiles(): array {
|
||||
self::init();
|
||||
$files = glob(self::$logDir . '/evershelf_*.log') ?: [];
|
||||
rsort($files);
|
||||
return array_map(fn($f) => [
|
||||
'file' => basename($f),
|
||||
'size_kb' => round(filesize($f) / 1024, 1),
|
||||
'mtime' => date('Y-m-d H:i:s', filemtime($f)),
|
||||
], $files);
|
||||
}
|
||||
|
||||
/** Current effective level name. */
|
||||
public static function levelName(): string {
|
||||
self::init();
|
||||
return ['DEBUG', 'INFO', 'WARN', 'ERROR'][self::$level];
|
||||
}
|
||||
|
||||
/** Current log file path. */
|
||||
public static function currentFile(): string {
|
||||
self::init();
|
||||
return self::$logFile;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// LoggingPDOStatement — wraps PDOStatement to time and log every execute()
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
class LoggingPDOStatement {
|
||||
private \PDOStatement $stmt;
|
||||
private string $sql;
|
||||
|
||||
public function __construct(\PDOStatement $stmt, string $sql) {
|
||||
$this->stmt = $stmt;
|
||||
$this->sql = $sql;
|
||||
}
|
||||
|
||||
public function execute(?array $params = null): bool {
|
||||
$t0 = microtime(true);
|
||||
$ok = $this->stmt->execute($params);
|
||||
$ms = round((microtime(true) - $t0) * 1000, 2);
|
||||
$ctx = ['ms' => $ms, 'rows' => $this->stmt->rowCount()];
|
||||
if ($ms > 500) $ctx['SLOW'] = true;
|
||||
EverLog::query($this->sql, $this->stmt->rowCount(), (microtime(true) - $t0));
|
||||
return $ok;
|
||||
}
|
||||
|
||||
public function fetch(int $mode = \PDO::FETCH_DEFAULT, ...$args): mixed {
|
||||
return $this->stmt->fetch($mode, ...$args);
|
||||
}
|
||||
|
||||
public function fetchAll(int $mode = \PDO::FETCH_DEFAULT, ...$args): array {
|
||||
return $this->stmt->fetchAll($mode ?: \PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function fetchColumn(int $col = 0): mixed {
|
||||
return $this->stmt->fetchColumn($col);
|
||||
}
|
||||
|
||||
public function rowCount(): int {
|
||||
return $this->stmt->rowCount();
|
||||
}
|
||||
|
||||
public function bindValue(int|string $param, mixed $value, int $type = \PDO::PARAM_STR): bool {
|
||||
return $this->stmt->bindValue($param, $value, $type);
|
||||
}
|
||||
|
||||
public function bindParam(int|string $param, mixed &$var, int $type = \PDO::PARAM_STR, int $maxLength = 0): bool {
|
||||
return $this->stmt->bindParam($param, $var, $type, $maxLength);
|
||||
}
|
||||
|
||||
public function closeCursor(): bool {
|
||||
return $this->stmt->closeCursor();
|
||||
}
|
||||
|
||||
public function setFetchMode(int $mode, mixed ...$args): bool {
|
||||
return $this->stmt->setFetchMode($mode, ...$args);
|
||||
}
|
||||
|
||||
public function __get(string $name): mixed {
|
||||
return $this->stmt->$name;
|
||||
}
|
||||
|
||||
public function __call(string $name, array $args): mixed {
|
||||
return $this->stmt->$name(...$args);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// LoggingPDO — wraps PDO to auto-log all prepare(), query(), exec()
|
||||
// Drop-in replacement: return LoggingPDO from getDB() instead of PDO.
|
||||
// Type hint: use PDO in all functions (LoggingPDO extends PDO).
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
class LoggingPDO extends \PDO {
|
||||
#[\ReturnTypeWillChange]
|
||||
public function prepare(string $query, array $options = []): LoggingPDOStatement|false {
|
||||
$stmt = parent::prepare($query, $options);
|
||||
if ($stmt === false) {
|
||||
EverLog::error('PDO::prepare failed', ['sql' => substr($query, 0, 200)]);
|
||||
return false;
|
||||
}
|
||||
return new LoggingPDOStatement($stmt, $query);
|
||||
}
|
||||
|
||||
public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): \PDOStatement|false {
|
||||
$t0 = microtime(true);
|
||||
$stmt = $fetchMode !== null
|
||||
? parent::query($query, $fetchMode, ...$fetchModeArgs)
|
||||
: parent::query($query);
|
||||
$elapsed = microtime(true) - $t0;
|
||||
if ($stmt !== false) {
|
||||
EverLog::query($query, $stmt->rowCount(), $elapsed);
|
||||
} else {
|
||||
EverLog::error('PDO::query failed', ['sql' => substr($query, 0, 200)]);
|
||||
}
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
public function exec(string $statement): int|false {
|
||||
// Skip WAL/PRAGMA logging below DEBUG (too noisy at startup)
|
||||
$isPragma = stripos(ltrim($statement), 'PRAGMA') === 0;
|
||||
$t0 = microtime(true);
|
||||
$result = parent::exec($statement);
|
||||
$elapsed = microtime(true) - $t0;
|
||||
if (!$isPragma) {
|
||||
EverLog::query($statement, $result === false ? 0 : $result, $elapsed);
|
||||
} elseif (EverLog::DEBUG >= 0) {
|
||||
// Log PRAGMAs only at DEBUG level
|
||||
EverLog::query($statement, is_int($result) ? $result : 0, $elapsed);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,57 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf Scale Gateway — Auto-discovery (auth + rate limit + LAN only).
|
||||
* 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", ...]}
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/lib/env.php';
|
||||
require_once __DIR__ . '/lib/security.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache');
|
||||
evershelfSendCorsHeaders();
|
||||
|
||||
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Simple rate limit: max 6 scans per minute per IP
|
||||
$rlDir = dirname(__DIR__) . '/data/rate_limits';
|
||||
if (!is_dir($rlDir)) {
|
||||
@mkdir($rlDir, 0755, true);
|
||||
}
|
||||
$rlFile = $rlDir . '/scale_discover_' . md5($_SERVER['REMOTE_ADDR'] ?? 'cli') . '.json';
|
||||
$now = time();
|
||||
$hits = [];
|
||||
if (file_exists($rlFile)) {
|
||||
$hits = array_filter(json_decode(file_get_contents($rlFile), true) ?: [], fn($t) => $t > $now - 60);
|
||||
}
|
||||
if (count($hits) >= 6) {
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'Too many discovery scans']);
|
||||
exit;
|
||||
}
|
||||
$hits[] = $now;
|
||||
@file_put_contents($rlFile, json_encode($hits), LOCK_EX);
|
||||
|
||||
$port = (int)($_GET['port'] ?? 8765);
|
||||
if ($port < 1 || $port > 65535) {
|
||||
$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 = evershelfLocalLanIp();
|
||||
$serverIp = localLanIp();
|
||||
$parts = explode('.', $serverIp);
|
||||
if (count($parts) !== 4) {
|
||||
echo json_encode(['error' => 'Cannot determine local subnet', 'found' => []]);
|
||||
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;
|
||||
@@ -70,28 +74,25 @@ while (!empty($candidates) && microtime(true) < $deadline) {
|
||||
$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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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 ($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]);
|
||||
@@ -99,16 +100,13 @@ while (!empty($candidates) && microtime(true) < $deadline) {
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($candidates as $s) {
|
||||
@fclose($s);
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (!$sock) continue;
|
||||
stream_set_timeout($sock, 2);
|
||||
|
||||
$key = base64_encode(random_bytes(16));
|
||||
@@ -126,13 +124,9 @@ foreach ($found_tcp as $ip) {
|
||||
$dl = microtime(true) + 2;
|
||||
while (microtime(true) < $dl && !feof($sock)) {
|
||||
$line = fgets($sock, 256);
|
||||
if ($line === false) {
|
||||
break;
|
||||
}
|
||||
if ($line === false) break;
|
||||
$resp .= $line;
|
||||
if ($line === "\r\n") {
|
||||
break;
|
||||
}
|
||||
if ($line === "\r\n") break;
|
||||
}
|
||||
fclose($sock);
|
||||
|
||||
@@ -144,4 +138,5 @@ foreach ($found_tcp as $ip) {
|
||||
echo json_encode([
|
||||
'found' => $gateways,
|
||||
'subnet' => rtrim($subnet, '.') . '.0/24',
|
||||
'server_ip' => $serverIp,
|
||||
]);
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
<?php
|
||||
/**
|
||||
* EverShelf Scale Gateway — Connection ping / test (SSRF-hardened)
|
||||
* 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
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/lib/env.php';
|
||||
require_once __DIR__ . '/lib/security.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache');
|
||||
|
||||
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['ok' => false, 'error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$rawUrl = $_GET['url'] ?? '';
|
||||
|
||||
if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
||||
@@ -23,7 +19,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
||||
}
|
||||
|
||||
$parsed = parse_url($rawUrl);
|
||||
$host = strtolower($parsed['host'] ?? '');
|
||||
$host = $parsed['host'] ?? '';
|
||||
$port = (int)($parsed['port'] ?? 8765);
|
||||
$path = ($parsed['path'] ?? '') ?: '/';
|
||||
|
||||
@@ -32,11 +28,6 @@ if (!$host || $port < 1 || $port > 65535) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!evershelfScaleHostAllowed($host)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Gateway host not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Try to open a TCP connection with a 5-second timeout
|
||||
$sock = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 5);
|
||||
if (!$sock) {
|
||||
|
||||
@@ -8,16 +8,6 @@
|
||||
* Usage: GET /api/scale_relay.php?url=ws%3A%2F%2F192.168.1.100%3A8765
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/lib/env.php';
|
||||
require_once __DIR__ . '/lib/security.php';
|
||||
|
||||
if (evershelfApiTokenRequired() && !evershelfApiTokenValid() && !evershelfIsSameOriginBrowser()) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized', 'api_token_required' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Input validation ──────────────────────────────────────────────────────────
|
||||
$rawUrl = $_GET['url'] ?? '';
|
||||
|
||||
@@ -29,7 +19,7 @@ if (!preg_match('#^ws://[0-9a-zA-Z][\w.\-]*(:\d{1,5})?(/.*)?$#', $rawUrl)) {
|
||||
}
|
||||
|
||||
$parsed = parse_url($rawUrl);
|
||||
$wsHost = strtolower($parsed['host'] ?? '');
|
||||
$wsHost = $parsed['host'] ?? '';
|
||||
$wsPort = (int)($parsed['port'] ?? 8765);
|
||||
$wsPath = ($parsed['path'] ?? '') ?: '/';
|
||||
|
||||
@@ -39,12 +29,6 @@ if (!$wsHost || $wsPort < 1 || $wsPort > 65535) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!evershelfScaleHostAllowed($wsHost)) {
|
||||
header('Content-Type: text/event-stream');
|
||||
echo 'data: ' . json_encode(['type' => 'error', 'message' => 'Gateway host not allowed']) . "\n\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── SSE headers ───────────────────────────────────────────────────────────────
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
|
||||
|
Before Width: | Height: | Size: 9.7 MiB |
|
Before Width: | Height: | Size: 9.7 MiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 144 KiB |
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* EverShelf core — API token storage and auth headers.
|
||||
*/
|
||||
const EVERSHELF_TOKEN_KEY = 'evershelf_api_token';
|
||||
|
||||
function getApiToken() {
|
||||
return localStorage.getItem(EVERSHELF_TOKEN_KEY) || '';
|
||||
}
|
||||
|
||||
function setApiToken(token) {
|
||||
const t = (token || '').trim();
|
||||
if (t) {
|
||||
localStorage.setItem(EVERSHELF_TOKEN_KEY, t);
|
||||
} else {
|
||||
localStorage.removeItem(EVERSHELF_TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function apiAuthHeaders() {
|
||||
const fromStorage = getApiToken();
|
||||
const fromSettingsField = document.getElementById('setting-settings-token')?.value.trim() || '';
|
||||
const token = fromSettingsField || fromStorage;
|
||||
if (!token) return {};
|
||||
return { 'X-API-Token': token };
|
||||
}
|
||||
|
||||
/** Fetch API token from server when loading the UI from the same origin. */
|
||||
async function ensureApiToken() {
|
||||
if (getApiToken()) return true;
|
||||
try {
|
||||
const res = await fetch('api/index.php?action=app_bootstrap', { cache: 'no-store' });
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
window._apiTokenRequired = !!data.api_token_required;
|
||||
if (data.api_token) {
|
||||
setApiToken(data.api_token);
|
||||
return true;
|
||||
}
|
||||
} catch (_) { /* offline / network */ }
|
||||
return !!getApiToken();
|
||||
}
|
||||
|
||||
function _promptApiTokenIfNeeded() {
|
||||
if (!window._apiTokenRequired) return;
|
||||
if (getApiToken()) return;
|
||||
const existing = document.getElementById('api-token-overlay');
|
||||
if (existing) return;
|
||||
const title = typeof t === 'function' ? t('startup.token_prompt_title') : '🔒 API Token';
|
||||
const hint = typeof t === 'function' ? t('startup.token_prompt_hint') : 'Enter API_TOKEN from .env';
|
||||
const btn = typeof t === 'function' ? t('startup.token_prompt_btn') : 'Continue';
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'api-token-overlay';
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content" style="max-width:420px;padding:20px">
|
||||
<h3>${title}</h3>
|
||||
<p class="settings-hint">${hint}</p>
|
||||
<input type="password" id="api-token-input" class="form-input" placeholder="API token">
|
||||
<button class="btn btn-primary full-width mt-2" id="api-token-save">${btn}</button>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById('api-token-save').onclick = () => {
|
||||
const v = document.getElementById('api-token-input').value.trim();
|
||||
if (v) {
|
||||
setApiToken(v);
|
||||
overlay.remove();
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.getApiToken = getApiToken;
|
||||
window.setApiToken = setApiToken;
|
||||
window.apiAuthHeaders = apiAuthHeaders;
|
||||
window.ensureApiToken = ensureApiToken;
|
||||
window._promptApiTokenIfNeeded = _promptApiTokenIfNeeded;
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* EverShelf core — safe HTML escaping (loaded before app.js).
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (str == null) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(str);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
window.escapeHtml = escapeHtml;
|
||||
@@ -1,19 +1,13 @@
|
||||
#!/bin/bash
|
||||
# Daily backup of EverShelf database (local only)
|
||||
# Retention follows BACKUP_RETENTION_DAYS from .env (default 3)
|
||||
# The database is NOT pushed to remote repositories.
|
||||
# Runs via cron: creates a local timestamped backup copy
|
||||
#
|
||||
# Example crontab entry:
|
||||
# 0 3 * * * /var/www/html/evershelf/backup.sh
|
||||
|
||||
set -euo pipefail
|
||||
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BACKUP_DIR="${INSTALL_DIR}/data/backups"
|
||||
ENV_FILE="${INSTALL_DIR}/.env"
|
||||
|
||||
RETENTION=3
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
val=$(grep -E '^BACKUP_RETENTION_DAYS=' "$ENV_FILE" | tail -1 | cut -d= -f2)
|
||||
if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then
|
||||
RETENTION="$val"
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
@@ -25,5 +19,5 @@ fi
|
||||
DATE=$(date '+%Y-%m-%d_%H%M')
|
||||
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
|
||||
|
||||
# Keep only the newest N backups
|
||||
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +$((RETENTION + 1)) | xargs -r rm --
|
||||
# Keep only the last 7 backups
|
||||
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Deny all direct HTTP access to runtime data (DB, tokens, caches, logs)
|
||||
Require all denied
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"2026-05": {
|
||||
"input_tokens": 4438300,
|
||||
"output_tokens": 1286760,
|
||||
"calls": 8374,
|
||||
"by_action": {},
|
||||
"by_model": {}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"ts":1779204302,"filename":"evershelf_2026-05-19_1525.db","size_kb":444}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -5,21 +5,14 @@ services:
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
# Persist database and runtime data
|
||||
- evershelf_data:/var/www/html/data
|
||||
# Mount your local .env configuration
|
||||
- ./.env:/var/www/html/.env:ro
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Europe/Rome
|
||||
networks:
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
evershelf_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
external: true
|
||||
backend:
|
||||
external: true
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" standalone='no'?>
|
||||
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
|
||||
<service-group>
|
||||
<name replace-wildcards="yes">EverShelf Pantry (%h)</name>
|
||||
<service>
|
||||
<type>_evershelf._tcp</type>
|
||||
<port>80</port>
|
||||
<txt-record>path=/api/</txt-record>
|
||||
<txt-record>version=1.0</txt-record>
|
||||
<txt-record>app=evershelf</txt-record>
|
||||
</service>
|
||||
</service-group>
|
||||
@@ -1,38 +0,0 @@
|
||||
# EverShelf — Architecture (modular layout)
|
||||
|
||||
```
|
||||
dispensa/
|
||||
├── api/
|
||||
│ ├── bootstrap.php # Shared init: env, security, DB, logger
|
||||
│ ├── index.php # HTTP handlers + router (split planned per domain)
|
||||
│ ├── database.php # SQLite schema & migrations
|
||||
│ ├── logger.php # Rotating file logger (logs/)
|
||||
│ ├── cron_smart_shopping.php # CLI cron (uses bootstrap + index handlers)
|
||||
│ ├── lib/
|
||||
│ │ ├── env.php # .env loader
|
||||
│ │ ├── constants.php # Paths & pricing constants
|
||||
│ │ ├── security.php # API auth, CORS, demo mode, scale allowlist
|
||||
│ │ ├── github.php # Encrypted GitHub Issues token
|
||||
│ │ └── cron_log.php # data/cron.log rotation
|
||||
│ └── scale_*.php # Scale gateway helpers (auth + SSRF guards)
|
||||
├── assets/
|
||||
│ ├── js/
|
||||
│ │ ├── core/ # auth.js, dom.js (loaded before app.js)
|
||||
│ │ └── app.js # SPA logic (domain modules: future split)
|
||||
│ └── vendor/ # Offline CDN fallbacks (quagga, transformers)
|
||||
├── data/ # Runtime data (.htaccess: deny all)
|
||||
├── logs/ # Application logs (.htaccess: deny all)
|
||||
└── scripts/ # migrate-env-security, fix-permissions, encrypt-gh-token
|
||||
```
|
||||
|
||||
## Security model
|
||||
|
||||
- **`API_TOKEN`** (or legacy **`SETTINGS_TOKEN`**): when set, every API action requires `X-API-Token` header or `?api_token=` (Home Assistant).
|
||||
- Secrets (`HA_TOKEN`, `TTS_TOKEN`, `GEMINI_API_KEY`) stay in `.env`; `get_settings` exposes only `*_set` flags.
|
||||
- **`GH_ISSUE_TOKEN_ENC`** + **`GH_ISSUE_TOKEN_KEY`**: AES-256-GCM encrypted GitHub Issues token.
|
||||
|
||||
## Planned refactors
|
||||
|
||||
1. Split `api/index.php` handlers into `api/handlers/{products,inventory,ai,shopping}.php`
|
||||
2. Split `assets/js/app.js` into ES modules under `assets/js/features/`
|
||||
3. Optional `npm run build` to minify JS/CSS (see `package.json`)
|
||||
@@ -36,12 +36,12 @@ 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 **"Grant permissions"** to **"✅ Permissions granted — Continue →"** (green) once all permissions are granted.
|
||||
The button transforms from "Concedi permessi" to **"✅ Permessi concessi — Continua →"** (green) once all permissions are granted.
|
||||
|
||||
### Step 4 — Server URL
|
||||
Enter your EverShelf server URL (e.g. `https://192.168.1.100/dispensa`).
|
||||
|
||||
**Or tap "Auto-discover"** to let the wizard scan your LAN:
|
||||
**Or tap "Rileva automaticamente"** to let the wizard scan your LAN:
|
||||
- 60 parallel threads, TCP pre-check, ports 80/443/8080/8443
|
||||
- Only scans your actual Wi-Fi/Ethernet subnet (VPN and cellular interfaces ignored)
|
||||
- Real-time feedback as hosts are tested
|
||||
@@ -62,23 +62,9 @@ All done — the web app loads in full-screen kiosk mode.
|
||||
|
||||
---
|
||||
|
||||
## Header Overlay Buttons
|
||||
|
||||
Three buttons are injected into the top-left of the web header by the kiosk app:
|
||||
|
||||
| Button | Action |
|
||||
|--------|--------|
|
||||
| **✕** | Exit kiosk mode (confirmation dialog) |
|
||||
| **↻** | Hard-refresh — clears WebView cache and reloads the app |
|
||||
| **⚙️** | Open EverShelf Settings |
|
||||
|
||||
The native Android settings button is permanently hidden once the overlay is injected — the **⚙️** web button replaces it entirely.
|
||||
|
||||
---
|
||||
|
||||
## Exiting Kiosk Mode
|
||||
|
||||
Tap the **✕** button in the header overlay (top-left). A confirmation dialog appears.
|
||||
Tap the **✕** button in the header (top-left). A confirmation dialog appears.
|
||||
|
||||
---
|
||||
|
||||
@@ -147,6 +133,6 @@ Requires Android Studio or JDK 17+ with the Android SDK.
|
||||
| `CAMERA` | Barcode scanning and AI photo identification |
|
||||
| `RECORD_AUDIO` | Voice input in AI chat |
|
||||
| `WAKE_LOCK` | Keep the screen on |
|
||||
| `REQUEST_INSTALL_PACKAGES` | Over-the-air kiosk self-updates (installs new APK from GitHub releases) |
|
||||
| `REQUEST_INSTALL_PACKAGES` | Install the Scale Gateway APK |
|
||||
| `ACCESS_WIFI_STATE` | LAN auto-discovery |
|
||||
| `REORDER_TASKS` | Bring the kiosk app to foreground when needed |
|
||||
| `REORDER_TASKS` | Bring app to foreground after gateway launch |
|
||||
|
||||
@@ -43,13 +43,15 @@ docker compose up -d
|
||||
|
||||
## AI Features
|
||||
|
||||
### "AI not available" error
|
||||
### 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
|
||||
@@ -60,7 +62,7 @@ max_execution_time = 120
|
||||
|
||||
## Shopping List (Bring!)
|
||||
|
||||
### "Bring! not configured" message in the shopping tab
|
||||
### "Bring! non configurato" message in the shopping tab
|
||||
|
||||
Add your Bring! credentials to `.env`:
|
||||
|
||||
@@ -88,7 +90,7 @@ BRING_PASSWORD=yourpassword
|
||||
### 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 **"⚖️ Read Scale"** first to activate the scale modal
|
||||
- Make sure you tapped "⚖️ Leggi bilancia" first to activate the scale modal
|
||||
- The weight must stabilize (stay within 10g) for the countdown to start
|
||||
|
||||
### Bluetooth scale not appearing in the gateway app
|
||||
@@ -107,19 +109,19 @@ BRING_PASSWORD=yourpassword
|
||||
- Try entering the URL manually instead of using auto-discovery
|
||||
- Check that the server responds on the expected port (80/443/8080/8443)
|
||||
|
||||
### Kiosk app update fails
|
||||
### Gateway install fails with an error dialog
|
||||
|
||||
The kiosk checks for a new release every 6 hours and downloads it from GitHub. If the install fails:
|
||||
The dialog shows the exact failure code. Common causes:
|
||||
|
||||
| Symptom | Fix |
|
||||
|---------|-----|
|
||||
| "Install from unknown sources" dialog | Enable the setting for the EverShelf Kiosk app in Android Settings |
|
||||
| Persistent failure after download | Force-stop the app, clear its data, and relaunch the update flow |
|
||||
| Not enough space | Free up storage on the device |
|
||||
| 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
|
||||
|
||||
Three buttons are always visible in the kiosk header overlay: **✕** (exit), **↻** (refresh), **⚙️** (settings). If the page failed to load entirely, tap **↻** first. If nothing is visible, restart the device.
|
||||
The ✕ button is injected into the header by the kiosk app. If the web app's header is covered or the page failed to load, try the hard refresh (↻) button. If neither is visible, triple-tap the page title area to access the developer settings.
|
||||
|
||||
### App is stuck in kiosk mode after a crash
|
||||
|
||||
@@ -137,7 +139,7 @@ The version is cached by the browser. Do a hard refresh:
|
||||
|
||||
### Transactions are missing from the log
|
||||
|
||||
The log shows the last 50 entries by default. Tap **"Load more"** to load more. Entries older than the database creation date won't appear.
|
||||
The log shows the last 50 entries by default. Tap "Carica altri" to load more. Entries older than the database creation date won't appear.
|
||||
|
||||
### "Can only undo transactions within 24 hours"
|
||||
|
||||
|
||||
@@ -83,9 +83,9 @@ Recipes stream live via Server-Sent Events so results appear as they are generat
|
||||
### AI Chat Assistant
|
||||
|
||||
Open **💬 Chat** to ask questions like:
|
||||
- "What can I make with eggs and pasta?"
|
||||
- "How long does cooked ham last once opened in the fridge?"
|
||||
- "Suggest a quick snack"
|
||||
- "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.
|
||||
|
||||
@@ -121,7 +121,7 @@ Configure `BRING_EMAIL` and `BRING_PASSWORD` in `.env` to enable.
|
||||
|
||||
## 🍳 Cooking Mode
|
||||
|
||||
Start cooking mode from any recipe by tapping **▶ Start Cooking**.
|
||||
Start cooking mode from any recipe by tapping **▶ Avvia cottura**.
|
||||
|
||||
### Features
|
||||
|
||||
@@ -132,7 +132,7 @@ Start cooking mode from any recipe by tapping **▶ Start Cooking**.
|
||||
- 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!" *(Enjoy your meal!)* spoken on the last step
|
||||
- **Recipe completion** — "Buon appetito!" spoken on the last step
|
||||
|
||||
---
|
||||
|
||||
@@ -155,8 +155,8 @@ Actions per item: Use, Throw away, Edit, Dismiss. Swipe or tap arrows to navigat
|
||||
Highlights suspicious quantities (e.g. "You have 0 eggs but used 12 this month"). Actions:
|
||||
- One-tap correction to the suggested quantity
|
||||
- Inline edit with free-form quantity
|
||||
- "🤖 Explain" for AI explanation
|
||||
- Dismiss (with current quantity shown: "The quantity is correct (2 pcs)")
|
||||
- "🤖 Spiega" for AI explanation
|
||||
- Dismiss (with current quantity shown: "La quantità è giusta (2 pz)")
|
||||
|
||||
### Anti-Waste Report
|
||||
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
# Home Assistant Integration
|
||||
|
||||
EverShelf integrates natively with [Home Assistant](https://www.home-assistant.io/) to bring your pantry data into your smart-home automations.
|
||||
|
||||
**Capabilities:**
|
||||
- 📡 **REST sensors** — expose pantry counts as HA sensor entities (expiring, expired, shopping list, total items)
|
||||
- 🔔 **Webhooks** — trigger HA automations on pantry events (expiry alerts, shopping additions, stock updates)
|
||||
- 📣 **Push notifications** — send alerts to your phone via any HA `notify.*` service
|
||||
- 🔊 **TTS on smart speakers** — read recipe steps aloud on any HA `media_player` entity
|
||||
- ⚙️ **In-app config panel** — configure everything from Settings → 🏠 tab (no need to edit `.env` manually)
|
||||
|
||||
---
|
||||
|
||||
## Quick Setup
|
||||
|
||||
1. **Generate a Long-Lived Access Token** in Home Assistant:
|
||||
- Open HA → your **Profile** (bottom-left avatar) → **Security** → **Long-Lived Access Tokens** → **Create Token**
|
||||
- Copy the generated token — you won't see it again.
|
||||
|
||||
2. **Open EverShelf Settings** → tab **🏠 Home Assistant**.
|
||||
|
||||
3. Fill in **Home Assistant URL** (e.g. `http://homeassistant.local:8123`) and paste the token.
|
||||
|
||||
4. Click **Test connection** — you should see ✅.
|
||||
|
||||
5. Enable the features you want (TTS, Webhooks, REST Sensors) and click **Save HA settings**.
|
||||
|
||||
---
|
||||
|
||||
## REST Sensors
|
||||
|
||||
Add EverShelf pantry data as native HA sensor entities that update automatically.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| URL | Returns | Sensor |
|
||||
|-----|---------|--------|
|
||||
| `/api/?action=ha_sensor` | Items expiring soon (≤`HA_EXPIRY_DAYS` days) | `sensor.evershelf_overview` |
|
||||
| `/api/?action=ha_sensor&sensor=expired` | Expired items count | `sensor.evershelf_expired` |
|
||||
| `/api/?action=ha_sensor&sensor=shopping` | Shopping list item count | `sensor.evershelf_shopping` |
|
||||
| `/api/?action=ha_sensor&sensor=total` | Total pantry items | `sensor.evershelf_total` |
|
||||
| `/api/?action=ha_sensor&sensor=product` | Full inventory — all items with complete details | `sensor.evershelf_products` |
|
||||
| `/api/?action=ha_sensor&sensor=product&id=42` | Full details for inventory row `id=42` | — |
|
||||
| `/api/?action=ha_sensor&sensor=product&name=milk` | Full details for items whose name contains "milk" | — |
|
||||
| `/api/?action=ha_sensor&sensor=product&location=frigo` | All items in a specific location | — |
|
||||
|
||||
### Generate & Copy YAML
|
||||
|
||||
In Settings → 🏠 Home Assistant → **REST Sensors** card, click **Copy YAML** to get a ready-to-paste `configuration.yaml` block that already contains your EverShelf URL.
|
||||
|
||||
### Manual YAML example
|
||||
|
||||
```yaml
|
||||
# configuration.yaml
|
||||
sensor:
|
||||
- platform: rest
|
||||
name: "EverShelf Overview"
|
||||
unique_id: evershelf_overview
|
||||
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor"
|
||||
scan_interval: 300 # seconds
|
||||
value_template: "{{ value_json.state }}"
|
||||
json_attributes:
|
||||
- expiring_soon
|
||||
- expiring_3d
|
||||
- expired_items
|
||||
- total_items
|
||||
- shopping_items
|
||||
- expiring_list # full product details for expiring items
|
||||
- expired_list # full product details for expired items
|
||||
- low_stock_list # full product details for items with quantity ≤ 1
|
||||
- next_expiry_name
|
||||
- next_expiry_date
|
||||
- days_to_next_expiry
|
||||
- last_updated
|
||||
unit_of_measurement: "items"
|
||||
|
||||
- platform: rest
|
||||
name: "EverShelf Shopping Count"
|
||||
unique_id: evershelf_shopping
|
||||
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=shopping"
|
||||
scan_interval: 180
|
||||
value_template: "{{ value_json.state }}"
|
||||
unit_of_measurement: "items"
|
||||
|
||||
# Full product inventory — each item includes all details (location, brand, category, …)
|
||||
- platform: rest
|
||||
name: "EverShelf Products"
|
||||
unique_id: evershelf_products
|
||||
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=product"
|
||||
scan_interval: 600
|
||||
value_template: "{{ value_json.state }}"
|
||||
json_attributes:
|
||||
- items
|
||||
- last_updated
|
||||
unit_of_measurement: "items"
|
||||
```
|
||||
|
||||
Restart Home Assistant after editing `configuration.yaml`.
|
||||
|
||||
Every product entry inside `expiring_list`, `expired_list`, `low_stock_list`, and `sensor=product` responses follows the same schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_id": 42,
|
||||
"inventory_id": 7,
|
||||
"name": "Latte intero",
|
||||
"brand": "Parmalat",
|
||||
"category": "Lattiero-caseari",
|
||||
"quantity": 2.0,
|
||||
"unit": "conf",
|
||||
"default_quantity": 1000.0,
|
||||
"package_unit": "ml",
|
||||
"location": "frigo",
|
||||
"expiry_date": "2025-06-15",
|
||||
"days_remaining": 3,
|
||||
"opened_at": "2025-06-10",
|
||||
"vacuum_sealed": false
|
||||
}
|
||||
```
|
||||
|
||||
Field details:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `product_id` | int | Products table ID |
|
||||
| `inventory_id` | int | Inventory row ID |
|
||||
| `name` | string | Product name |
|
||||
| `brand` | string\|null | Brand (if set) |
|
||||
| `category` | string\|null | Category (if set) |
|
||||
| `quantity` | float | Current quantity in inventory |
|
||||
| `unit` | string | Unit (`conf`, `g`, `ml`, `pz`, …) |
|
||||
| `default_quantity` | float | Default package size (e.g. 1000 for 1-litre carton) |
|
||||
| `package_unit` | string\|null | Unit of the default package (`g`, `ml`) |
|
||||
| `location` | string\|null | Storage location (`frigo`, `freezer`, `dispensa`, …) |
|
||||
| `expiry_date` | string\|null | ISO date `YYYY-MM-DD` |
|
||||
| `days_remaining` | int\|null | Days until expiry (negative = already expired) |
|
||||
| `opened_at` | string\|null | ISO date when the package was opened |
|
||||
| `vacuum_sealed` | bool | Whether the item is vacuum-sealed |
|
||||
|
||||
---
|
||||
|
||||
## Webhook Automations
|
||||
|
||||
EverShelf fires an HTTP POST to your HA webhook URL when pantry events occur.
|
||||
|
||||
### Create the HA Webhook Automation
|
||||
|
||||
1. HA → **Settings** → **Automations & Scenes** → **Create Automation**
|
||||
2. Click **Add Trigger** → choose **Webhook**
|
||||
3. HA generates a **Webhook ID** — copy it
|
||||
4. Paste the ID into **Settings → 🏠 Home Assistant → Webhook ID**
|
||||
5. Select which events should trigger the webhook
|
||||
|
||||
### Supported Events
|
||||
|
||||
| Event key | When it fires |
|
||||
|-----------|--------------|
|
||||
| `expiry` | Daily cron — items expiring within `HA_EXPIRY_DAYS` days |
|
||||
| `shopping_add` | Item added to the shopping list |
|
||||
| `stock_update` | Inventory quantity changed |
|
||||
| `barcode_scan` | (reserved for future use) |
|
||||
|
||||
### Webhook Payload (POST body)
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "expiry_alert",
|
||||
"timestamp": "2025-06-12T08:00:00+00:00",
|
||||
"data": {
|
||||
"type": "expiring_soon",
|
||||
"count": 3,
|
||||
"days": 3,
|
||||
"summary": "Milk, Yogurt, Butter",
|
||||
"items": [
|
||||
{
|
||||
"product_id": 42,
|
||||
"inventory_id": 7,
|
||||
"name": "Milk",
|
||||
"brand": "Parmalat",
|
||||
"category": "Dairy",
|
||||
"quantity": 2.0,
|
||||
"unit": "conf",
|
||||
"default_quantity": 1000.0,
|
||||
"package_unit": "ml",
|
||||
"location": "frigo",
|
||||
"expiry_date": "2025-06-14",
|
||||
"days_remaining": 2,
|
||||
"opened_at": "2025-06-10",
|
||||
"vacuum_sealed": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Expiry Alert → Telegram
|
||||
|
||||
```yaml
|
||||
alias: EverShelf Expiry Alert
|
||||
trigger:
|
||||
- platform: webhook
|
||||
webhook_id: "evershelf_webhook_abc123" # ← your Webhook ID
|
||||
action:
|
||||
- service: notify.telegram_bot
|
||||
data:
|
||||
message: >
|
||||
🥫 EverShelf: {{ trigger.json.data.count }} product(s) expiring soon
|
||||
{% for item in trigger.json.data.items %}
|
||||
— {{ item.name }}{% if item.brand %} ({{ item.brand }}){% endif %} ·
|
||||
{{ item.quantity }} {{ item.unit }} · 📍 {{ item.location }} ·
|
||||
expires {{ item.expiry_date }} ({{ item.days_remaining }} days)
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### Example: Automation on location
|
||||
|
||||
You can filter by location in the automation template to only alert for fridge items:
|
||||
|
||||
```yaml
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ trigger.json.data.items | selectattr('location','eq','frigo') | list | length > 0 }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Push Notifications
|
||||
|
||||
If you prefer to receive push alerts without using webhooks, configure a **HA notify service** directly:
|
||||
|
||||
1. Find your notify service name in HA: **Developer Tools → Services** → search `notify`
|
||||
2. Paste it into **Settings → 🏠 → Notify service** (e.g. `notify.mobile_app_my_phone`)
|
||||
3. Save
|
||||
|
||||
EverShelf will call this service from the cron job whenever expiry alerts fire.
|
||||
|
||||
---
|
||||
|
||||
## TTS on Smart Speakers
|
||||
|
||||
Read recipe steps aloud on an Amazon Echo, Google Home, Sonos, or any HA `media_player`.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. Enter the **Entity ID** of your media player (e.g. `media_player.kitchen_display`)
|
||||
- Find it in HA: **Developer Tools → States**
|
||||
2. Click **Apply HA preset to TTS tab** — this auto-fills the TTS tab with the correct HA endpoint and auth headers
|
||||
3. Save settings
|
||||
|
||||
### How it Works
|
||||
|
||||
When recipe step TTS is triggered, EverShelf calls:
|
||||
|
||||
```
|
||||
POST /api/services/tts/speak
|
||||
Authorization: Bearer <HA_TOKEN>
|
||||
{
|
||||
"entity_id": "media_player.kitchen_display",
|
||||
"message": "Add 200 g of flour and mix well."
|
||||
}
|
||||
```
|
||||
|
||||
The request is proxied through the EverShelf PHP backend (avoids CORS / mixed-content issues).
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
All settings are configurable from `.env` or from the in-app Settings panel.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `HA_ENABLED` | `false` | Master switch for all HA features |
|
||||
| `HA_URL` | _(empty)_ | Base URL of HA instance, no trailing slash |
|
||||
| `HA_TOKEN` | _(empty)_ | Long-Lived Access Token |
|
||||
| `HA_TTS_ENTITY` | _(empty)_ | `media_player` entity for TTS |
|
||||
| `HA_WEBHOOK_ID` | _(empty)_ | Webhook trigger ID from HA automation |
|
||||
| `HA_WEBHOOK_EVENTS` | `expiry,shopping_add,stock_update` | Comma-separated list of events |
|
||||
| `HA_NOTIFY_SERVICE` | _(empty)_ | HA notify service (e.g. `notify.mobile_app_phone`) |
|
||||
| `HA_EXPIRY_DAYS` | `3` | Days before expiry to trigger the daily alert |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Test shows ❌ "Connection failed"**
|
||||
- Verify the URL is reachable from the EverShelf server (not just your browser)
|
||||
- If using HTTPS with a self-signed certificate, the server-side cURL request may fail — use HTTP on the local network instead
|
||||
- Check that port 8123 (or your custom port) is open on the HA host
|
||||
|
||||
**Test shows ❌ "bad_token"**
|
||||
- The Long-Lived Access Token may have expired or been revoked — generate a new one in HA Profile
|
||||
|
||||
**Webhook not firing**
|
||||
- Confirm HA_ENABLED=true and the Webhook ID is exactly as shown in HA
|
||||
- Check the EverShelf cron is running (`/api/cron_smart_shopping.php` every 5 minutes)
|
||||
- For shopping/stock events: verify the event name is in `HA_WEBHOOK_EVENTS`
|
||||
|
||||
**TTS not speaking**
|
||||
- Ensure the media player entity is online in HA (check its state in Developer Tools)
|
||||
- Try the "Apply HA preset to TTS tab" button and send a test from the TTS tab
|
||||
- Check HA logs for `tts.speak` errors (some platforms require `tts_options`)
|
||||
|
||||
**Sensors show unavailable in HA**
|
||||
- The EverShelf URL must be reachable from the HA host
|
||||
- If running EverShelf behind a reverse proxy, ensure `/api/` is accessible
|
||||
- Use `scan_interval` ≥ 60 to avoid hammering the server
|
||||
@@ -47,11 +47,8 @@ All data stays on your server. No cloud, no subscriptions.
|
||||
## 🆕 What's New
|
||||
|
||||
### v1.7.13 (2026-05-16)
|
||||
- **Fix:** Kiosk Settings button (⚙️) added to the web overlay — tapping the camera button no longer accidentally opens kiosk settings
|
||||
- **Fix:** Opened-item expiry badge is now consistent with the top banner: low-risk items (jams, condiments) show amber ⚠️ "Check soon" instead of misleading red ⛔ "Expired"
|
||||
- **Cooking Mode:** 3D wheel UI with perspective card flip, ghost steps (prev/next), float animation, and full `prefers-reduced-motion` support
|
||||
- **CI:** `data/category_ai_cache.json` added to `.gitignore`
|
||||
- **Critical fix (DB):** Fresh-install crash resolved — `transactions` schema was missing the `undone` column
|
||||
- **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
|
||||
@@ -84,7 +81,7 @@ EverShelf/
|
||||
├── 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) — DEPRECATED, built into kiosk since v1.6.0
|
||||
└── evershelf-scale-gateway/ # Android BLE gateway app (Kotlin)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -75,7 +75,7 @@ In EverShelf **Settings → Scale**:
|
||||
|
||||
### 4. Connect your scale
|
||||
|
||||
Tap **"Find Bluetooth Scales"**. Make sure your scale is powered on. Tap it in the list to pair and connect.
|
||||
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is powered on. Tap it in the list to pair and connect.
|
||||
|
||||
---
|
||||
|
||||
@@ -84,7 +84,7 @@ Tap **"Find Bluetooth Scales"**. Make sure your scale is powered on. Tap it in t
|
||||
When scale integration is enabled:
|
||||
|
||||
1. Open the **Add** or **Use** form for any product with unit `g` or `ml`
|
||||
2. A **"⚖️ Read Scale"** button appears
|
||||
2. A **"⚖️ Leggi bilancia"** button appears
|
||||
3. Tap it — a live weight display appears with a stability indicator
|
||||
4. Step on or place the product on the scale
|
||||
5. When the reading stabilizes, a **5-second countdown** starts
|
||||
@@ -127,7 +127,7 @@ Every 6 hours the gateway app checks GitHub releases. If a newer version is avai
|
||||
### Weight not appearing in EverShelf
|
||||
- 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 "Disconnect / Reconnect" in the gateway app to refresh the WebSocket connection
|
||||
- Tap "Disconnetti / Riconnetti" in the gateway app to refresh the WebSocket connection
|
||||
|
||||
### "Mixed content" error in browser
|
||||
- Make sure you are accessing EverShelf over HTTPS (not plain HTTP)
|
||||
|
||||
@@ -5,14 +5,14 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "it.dadaloop.evershelf.kiosk"
|
||||
compileSdk = 35
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 20
|
||||
versionName = "1.7.19"
|
||||
targetSdk = 34
|
||||
versionCode = 13
|
||||
versionName = "1.7.2"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -18,9 +18,7 @@ import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.media.AudioManager
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowInsetsController
|
||||
@@ -101,20 +99,6 @@ class KioskActivity : AppCompatActivity() {
|
||||
// Pending WebView permission request
|
||||
private var pendingWebPermission: PermissionRequest? = null
|
||||
|
||||
private fun safeEvalJs(script: String) {
|
||||
if (!::webView.isInitialized) return
|
||||
if (isFinishing || isDestroyed) return
|
||||
if (webView.visibility != View.VISIBLE) return
|
||||
runCatching { webView.evaluateJavascript(script, null) }
|
||||
.onFailure {
|
||||
ErrorReporter.reportMessage(
|
||||
type = "webview-js-bridge-error",
|
||||
message = "Failed to deliver JS callback to WebView",
|
||||
extra = mapOf("error" to (it.message ?: "unknown"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FILE_CHOOSER_REQUEST = 1002
|
||||
private const val PERMISSION_REQUEST_CODE = 1003
|
||||
@@ -129,9 +113,7 @@ class KioskActivity : AppCompatActivity() {
|
||||
private const val KEY_SCREENSAVER = "screensaver_enabled"
|
||||
private const val KIOSK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk"
|
||||
private const val SPLASH_DURATION = 1500L
|
||||
// Use the kiosk-specific rolling release tag so version comparison is always
|
||||
// against the KIOSK version, not the webapp version (they diverge).
|
||||
private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/tags/kiosk-latest"
|
||||
private const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
|
||||
// Keys for persisting a pending update across restarts
|
||||
private const val KEY_PENDING_UPDATE_VERSION = "pending_update_version"
|
||||
private const val KEY_PENDING_UPDATE_URL = "pending_update_url"
|
||||
@@ -160,25 +142,6 @@ class KioskActivity : AppCompatActivity() {
|
||||
if (res == TextToSpeech.LANG_MISSING_DATA || res == TextToSpeech.LANG_NOT_SUPPORTED) {
|
||||
tts?.language = Locale.getDefault()
|
||||
}
|
||||
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {}
|
||||
override fun onDone(utteranceId: String?) {
|
||||
runOnUiThread {
|
||||
safeEvalJs("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')")
|
||||
}
|
||||
}
|
||||
@Deprecated("Deprecated in API 21")
|
||||
override fun onError(utteranceId: String?) {
|
||||
runOnUiThread {
|
||||
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')")
|
||||
}
|
||||
}
|
||||
override fun onError(utteranceId: String?, errorCode: Int) {
|
||||
runOnUiThread {
|
||||
safeEvalJs("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)")
|
||||
}
|
||||
}
|
||||
})
|
||||
ttsReady = true
|
||||
}
|
||||
}
|
||||
@@ -501,10 +464,7 @@ class KioskActivity : AppCompatActivity() {
|
||||
if (!ttsReady) return
|
||||
engine.setSpeechRate(rate.coerceIn(0.1f, 4f))
|
||||
engine.setPitch(pitch.coerceIn(0.1f, 4f))
|
||||
val params = Bundle().apply {
|
||||
putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, AudioManager.STREAM_MUSIC)
|
||||
}
|
||||
engine.speak(text, TextToSpeech.QUEUE_FLUSH, params, "kiosk_tts")
|
||||
engine.speak(text, android.speech.tts.TextToSpeech.QUEUE_FLUSH, null, "kiosk_tts")
|
||||
}
|
||||
@JavascriptInterface
|
||||
fun stopSpeech() { tts?.stop() }
|
||||
@@ -555,17 +515,6 @@ class KioskActivity : AppCompatActivity() {
|
||||
btnSettings.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Open the native SettingsActivity from the webapp settings page.
|
||||
* Allows configuring server URL, BLE scale and screensaver without
|
||||
* the user having to find the native gear button.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun openNativeSettings() {
|
||||
runOnUiThread {
|
||||
startActivity(Intent(this@KioskActivity, SettingsActivity::class.java))
|
||||
}
|
||||
}
|
||||
}, "_kioskBridge")
|
||||
|
||||
val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local"
|
||||
@@ -643,79 +592,6 @@ class KioskActivity : AppCompatActivity() {
|
||||
webView.evaluateJavascript("$jsCallback($escaped)", null)
|
||||
}
|
||||
}
|
||||
|
||||
val currentKiosk = try {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
|
||||
} catch (_: Exception) { "" }
|
||||
|
||||
val installedVc: Long = try {
|
||||
val pi = packageManager.getPackageInfo(packageName, 0)
|
||||
if (Build.VERSION.SDK_INT >= 28) pi.longVersionCode
|
||||
else @Suppress("DEPRECATION") pi.versionCode.toLong()
|
||||
} catch (_: Exception) { -1L }
|
||||
|
||||
fun semverNewer(remote: String, local: String): Boolean {
|
||||
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
for (i in 0 until maxOf(r.size, l.size)) {
|
||||
val rv = r.getOrElse(i) { 0 }
|
||||
val lv = l.getOrElse(i) { 0 }
|
||||
if (rv != lv) return rv > lv
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun needsUpdate(remoteVersion: String, remoteVc: Long): Boolean = when {
|
||||
remoteVc > 0 && installedVc >= 0 -> remoteVc > installedVc
|
||||
currentKiosk.isNotEmpty() && remoteVersion.matches(Regex("\\d+\\.\\d+.*")) ->
|
||||
semverNewer(remoteVersion, currentKiosk)
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun applyUpdate(remoteVersion: String, apkUrl: String) {
|
||||
val result = JSONObject()
|
||||
.put("has_update", true)
|
||||
.put("current", currentKiosk)
|
||||
.put("latest", remoteVersion)
|
||||
.put("apk_url", apkUrl)
|
||||
notifyJs(result)
|
||||
prefs.edit()
|
||||
.putString(KEY_PENDING_UPDATE_VERSION, remoteVersion)
|
||||
.putString(KEY_PENDING_UPDATE_URL, apkUrl)
|
||||
.apply()
|
||||
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk → $remoteVersion", apkUrl) }
|
||||
}
|
||||
|
||||
// 1) Prefer LAN/self-hosted update (no GitHub required)
|
||||
val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trim().trimEnd('/')
|
||||
if (baseUrl.isNotEmpty()) {
|
||||
try {
|
||||
val localApi = "$baseUrl/api/index.php?action=kiosk_update"
|
||||
val conn = openTrustedConnection(localApi)
|
||||
conn.connectTimeout = 5000
|
||||
conn.readTimeout = 5000
|
||||
if (conn.responseCode == 200) {
|
||||
val localJson = JSONObject(conn.inputStream.bufferedReader().readText())
|
||||
conn.disconnect()
|
||||
if (localJson.optBoolean("success")) {
|
||||
val remoteVersion = localJson.optString("version", "")
|
||||
val remoteVc = localJson.optLong("version_code", -1L)
|
||||
val apkUrl = localJson.optString("apk_url", "")
|
||||
if (apkUrl.isNotEmpty() && needsUpdate(remoteVersion, remoteVc)) {
|
||||
applyUpdate(remoteVersion, apkUrl)
|
||||
return@Thread
|
||||
}
|
||||
if (!needsUpdate(remoteVersion, remoteVc)) {
|
||||
notifyJs(JSONObject().put("has_update", false).put("source", "local"))
|
||||
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
} else conn.disconnect()
|
||||
} catch (_: Exception) { /* fall through to GitHub */ }
|
||||
}
|
||||
|
||||
// 2) GitHub release fallback (requires internet)
|
||||
try {
|
||||
val conn = URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
@@ -730,58 +606,81 @@ class KioskActivity : AppCompatActivity() {
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
conn.disconnect()
|
||||
val json = JSONObject(body)
|
||||
val bodyText = json.optString("body", "")
|
||||
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
|
||||
val remoteKioskVersion = Regex("""kiosk-v?(\d+\.\d+(?:\.\d+)?)""")
|
||||
.find(bodyText)?.groupValues?.get(1)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: norm(json.optString("tag_name", ""))
|
||||
val latestTag = json.optString("tag_name", "")
|
||||
if (latestTag.isEmpty()) {
|
||||
notifyJs(JSONObject().put("has_update", false).put("error", "no tag"))
|
||||
return@Thread
|
||||
}
|
||||
|
||||
val remoteVc = Regex("""versionCode[=:\s(]+(\d+)""", RegexOption.IGNORE_CASE)
|
||||
.find(bodyText)?.groupValues?.get(1)?.toLongOrNull() ?: -1L
|
||||
val currentKiosk = try {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
|
||||
} catch (_: Exception) { "" }
|
||||
|
||||
// Strip any non-numeric prefix so "kiosk-1.7.0", "v1.7.0", "kiosk-v1.7.1"
|
||||
// all normalise to "1.7.0" / "1.7.1" for comparison.
|
||||
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
|
||||
val isSemver = norm(latestTag).matches(Regex("\\d+\\.\\d+.*"))
|
||||
|
||||
// Compare semver: returns true if `remote` is strictly greater than `local`
|
||||
fun semverNewer(remote: String, local: String): Boolean {
|
||||
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
val len = maxOf(r.size, l.size)
|
||||
for (i in 0 until len) {
|
||||
val rv = r.getOrElse(i) { 0 }
|
||||
val lv = l.getOrElse(i) { 0 }
|
||||
if (rv != lv) return rv > lv
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
val assets = json.optJSONArray("assets")
|
||||
var kioskApkUrl = ""
|
||||
if (assets != null) {
|
||||
for (i in 0 until assets.length()) {
|
||||
val a = assets.getJSONObject(i)
|
||||
val url = a.optString("browser_download_url", "")
|
||||
if (url.endsWith(".apk", ignoreCase = true) && url.isNotEmpty()) {
|
||||
kioskApkUrl = url; break
|
||||
}
|
||||
val a = assets.getJSONObject(i)
|
||||
val name = a.optString("name", "").lowercase()
|
||||
val url = a.optString("browser_download_url", "")
|
||||
if (name.contains("kiosk") && url.isNotEmpty()) kioskApkUrl = url
|
||||
}
|
||||
}
|
||||
if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL
|
||||
|
||||
if (!needsUpdate(remoteKioskVersion, remoteVc)) {
|
||||
notifyJs(JSONObject().put("has_update", false))
|
||||
// Only flag an update when the remote tag is parseable as semver AND
|
||||
// the remote version is strictly greater than the installed version.
|
||||
// Non-semver tags (e.g. "kiosk-latest", "rolling") cannot be compared
|
||||
// numerically → treat as "no update" to avoid false positives.
|
||||
val kioskNeedsUpdate = currentKiosk.isNotEmpty() &&
|
||||
isSemver && semverNewer(norm(latestTag), norm(currentKiosk))
|
||||
|
||||
val result = JSONObject()
|
||||
.put("has_update", kioskNeedsUpdate)
|
||||
.put("current", currentKiosk)
|
||||
.put("latest", latestTag)
|
||||
.put("apk_url", kioskApkUrl)
|
||||
|
||||
notifyJs(result)
|
||||
|
||||
if (!kioskNeedsUpdate) {
|
||||
// Clear any stale pending update if the current version is now up to date
|
||||
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
|
||||
return@Thread
|
||||
}
|
||||
applyUpdate(remoteKioskVersion, kioskApkUrl)
|
||||
|
||||
// Persist the pending update so the banner reappears after a crash/restart
|
||||
prefs.edit()
|
||||
.putString(KEY_PENDING_UPDATE_VERSION, latestTag)
|
||||
.putString(KEY_PENDING_UPDATE_URL, kioskApkUrl)
|
||||
.apply()
|
||||
|
||||
val label = if (isSemver) "$currentKiosk → $latestTag" else latestTag
|
||||
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $label", kioskApkUrl) }
|
||||
} catch (e: Exception) {
|
||||
notifyJs(JSONObject().put("has_update", false).put("error", e.message ?: "network error"))
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
/** HTTPS with self-signed cert support (LAN servers). */
|
||||
private fun openTrustedConnection(urlStr: String): java.net.HttpURLConnection {
|
||||
val conn = URL(urlStr).openConnection()
|
||||
if (conn is javax.net.ssl.HttpsURLConnection) {
|
||||
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : javax.net.ssl.X509TrustManager {
|
||||
override fun checkClientTrusted(c: Array<java.security.cert.X509Certificate>?, t: String?) {}
|
||||
override fun checkServerTrusted(c: Array<java.security.cert.X509Certificate>?, t: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
})
|
||||
val sc = javax.net.ssl.SSLContext.getInstance("TLS")
|
||||
sc.init(null, trustAll, java.security.SecureRandom())
|
||||
conn.sslSocketFactory = sc.socketFactory
|
||||
conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
|
||||
}
|
||||
return conn as java.net.HttpURLConnection
|
||||
}
|
||||
|
||||
/**
|
||||
* On resume: if a previous session detected an available update and saved it to prefs,
|
||||
* restore the update banner immediately without a network round-trip.
|
||||
@@ -855,13 +754,7 @@ class KioskActivity : AppCompatActivity() {
|
||||
val q = DownloadManager.Query().setFilterById(downloadId)
|
||||
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
|
||||
var ok = false
|
||||
var dmStatus = -1
|
||||
var dmReason = -1
|
||||
if (c.moveToFirst()) {
|
||||
dmStatus = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
dmReason = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON))
|
||||
ok = dmStatus == DownloadManager.STATUS_SUCCESSFUL
|
||||
}
|
||||
if (c.moveToFirst()) ok = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) == DownloadManager.STATUS_SUCCESSFUL
|
||||
c.close()
|
||||
if (ok) {
|
||||
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
|
||||
@@ -871,12 +764,7 @@ class KioskActivity : AppCompatActivity() {
|
||||
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
|
||||
setInstallUI("\u274C", getString(R.string.install_error_download), getString(R.string.install_error_download_detail), 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
|
||||
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
|
||||
ErrorReporter.reportMessage(
|
||||
"install_download_failed",
|
||||
"DownloadManager returned failure for URL: $apkUrl",
|
||||
mapOf("dm_status" to dmStatus, "dm_reason" to dmReason,
|
||||
"device" to buildDeviceLabel())
|
||||
)
|
||||
ErrorReporter.reportMessage("install_download_failed", "DownloadManager returned failure for URL: $apkUrl")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -903,52 +791,6 @@ class KioskActivity : AppCompatActivity() {
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
// ── Pre-install validation via PackageManager ──────────────────────
|
||||
// This catches version-downgrade or same-version attempts before PackageInstaller
|
||||
// gets them (which would silently fail with STATUS_FAILURE=1 on many OEMs).
|
||||
@Suppress("DEPRECATION")
|
||||
val apkInfo = try { packageManager.getPackageArchiveInfo(file.absolutePath, 0) } catch (_: Exception) { null }
|
||||
if (apkInfo != null) {
|
||||
// Wrong package: would always fail with STATUS_FAILURE=1
|
||||
if (apkInfo.packageName != packageName) {
|
||||
val detail = "APK package=${apkInfo.packageName}, expected=$packageName"
|
||||
setInstallUI("\u274C", "APK non valido", detail, 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
|
||||
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
|
||||
ErrorReporter.reportMessage("install_wrong_package", detail, mapOf("apk_pkg" to apkInfo.packageName, "expected" to packageName), forceReport = true)
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
// Version downgrade or same versionCode: Android rejects it
|
||||
@Suppress("DEPRECATION")
|
||||
val apkVc: Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
apkInfo.longVersionCode
|
||||
else
|
||||
apkInfo.versionCode.toLong()
|
||||
val installedVc: Long = try {
|
||||
@Suppress("DEPRECATION")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
packageManager.getPackageInfo(packageName, 0).longVersionCode
|
||||
else
|
||||
packageManager.getPackageInfo(packageName, 0).versionCode.toLong()
|
||||
} catch (_: Exception) { -1L }
|
||||
|
||||
if (installedVc >= 0 && apkVc <= installedVc) {
|
||||
// Same or older version — no real update, dismiss banner silently
|
||||
runOnUiThread {
|
||||
updateBanner.visibility = View.GONE
|
||||
bannerProgressBar.visibility = View.GONE
|
||||
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
|
||||
}
|
||||
ErrorReporter.reportMessage(
|
||||
"install_no_upgrade",
|
||||
"APK versionCode=$apkVc (${apkInfo.versionName}) ≤ installed=$installedVc — not an upgrade",
|
||||
mapOf("apk_vc" to apkVc, "apk_ver" to (apkInfo.versionName ?: ""), "installed_vc" to installedVc),
|
||||
forceReport = true
|
||||
)
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Only kiosk self-update is handled; gateway is now integrated
|
||||
val targetPkg = packageName
|
||||
installWithPackageInstaller(file, targetPkg)
|
||||
@@ -960,11 +802,6 @@ class KioskActivity : AppCompatActivity() {
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
// Note: setAppPackageName() is intentionally omitted — it causes STATUS_FAILURE (1)
|
||||
// on some OEM/Android versions even when the package name is correct.
|
||||
// setInstallReason is required on Android 14+ (API 34+) for PackageInstaller
|
||||
// to accept self-updates; without it Android 16 returns STATUS_FAILURE=1.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
params.setInstallReason(android.content.pm.PackageManager.INSTALL_REASON_USER)
|
||||
}
|
||||
val sessionId = pi.createSession(params)
|
||||
val session = pi.openSession(sessionId)
|
||||
try {
|
||||
|
||||
@@ -123,9 +123,6 @@ class SettingsActivity : AppCompatActivity() {
|
||||
// Back
|
||||
findViewById<android.widget.ImageButton>(R.id.btnBack).setOnClickListener { finish() }
|
||||
|
||||
// Advanced settings → back to webapp (where HA, Gemini, Bring! etc. are configured)
|
||||
findViewById<MaterialButton>(R.id.btnOpenAppSettings).setOnClickListener { finish() }
|
||||
|
||||
// Test connection
|
||||
findViewById<MaterialButton>(R.id.btnTestConnection).setOnClickListener { testConnection() }
|
||||
|
||||
|
||||
@@ -58,10 +58,8 @@ import javax.net.ssl.X509TrustManager
|
||||
* 2 — Permissions rationale + grant
|
||||
* 3 — Server URL + auto-discovery + connection test
|
||||
* 4 — Smart scale question → gateway info + install
|
||||
* 5 — Features (screensaver / prices / meal-plan / zero-waste)
|
||||
* 6 — Gemini AI key (optional, auto-skipped if already set)
|
||||
* 7 — Bring! credentials (optional, auto-skipped if already set)
|
||||
* 8 — Done
|
||||
* 5 — Screensaver toggle (NEW)
|
||||
* 6 — Done
|
||||
*/
|
||||
class SetupActivity : AppCompatActivity() {
|
||||
|
||||
@@ -75,8 +73,6 @@ class SetupActivity : AppCompatActivity() {
|
||||
private lateinit var stepServer: LinearLayout
|
||||
private lateinit var stepScale: LinearLayout
|
||||
private lateinit var stepScreensaver: LinearLayout
|
||||
private lateinit var stepGemini: LinearLayout
|
||||
private lateinit var stepBring: LinearLayout
|
||||
private lateinit var stepDone: LinearLayout
|
||||
|
||||
// Progress dots
|
||||
@@ -114,14 +110,6 @@ class SetupActivity : AppCompatActivity() {
|
||||
|
||||
// Screensaver step
|
||||
private lateinit var setupSwitchScreensaver: SwitchMaterial
|
||||
private lateinit var setupSwitchPrices: SwitchMaterial
|
||||
private lateinit var setupSwitchMealPlan: SwitchMaterial
|
||||
private lateinit var setupSwitchZeroWaste: SwitchMaterial
|
||||
|
||||
// Gemini + Bring steps
|
||||
private lateinit var setupGeminiKeyEdit: EditText
|
||||
private lateinit var setupBringEmailEdit: EditText
|
||||
private lateinit var setupBringPasswordEdit: EditText
|
||||
|
||||
// Done step
|
||||
private lateinit var summaryText: TextView
|
||||
@@ -140,12 +128,6 @@ class SetupActivity : AppCompatActivity() {
|
||||
private const val KEY_HAS_SCALE = "has_scale"
|
||||
private const val KEY_LANGUAGE = "kiosk_language"
|
||||
private const val KEY_SCREENSAVER = "screensaver_enabled"
|
||||
private const val KEY_PRICE_ENABLED = "price_enabled"
|
||||
private const val KEY_MEAL_PLAN = "meal_plan_enabled"
|
||||
private const val KEY_ZEROWASTE_TIPS = "zerowaste_tips_enabled"
|
||||
private const val KEY_GEMINI_KEY = "gemini_api_key"
|
||||
private const val KEY_BRING_EMAIL = "bring_email"
|
||||
private const val KEY_BRING_PASSWORD = "bring_password"
|
||||
private const val PERMISSION_REQUEST_CODE = 2004
|
||||
private const val BLE_PERMISSION_REQUEST = 2006
|
||||
|
||||
@@ -196,11 +178,8 @@ class SetupActivity : AppCompatActivity() {
|
||||
|
||||
override fun onBackPressed() {
|
||||
when (currentStep) {
|
||||
0 -> confirmExit()
|
||||
1 -> showStep(0) // back to language
|
||||
8 -> showStep(7) // done → bring
|
||||
7 -> showStep(6) // bring → gemini
|
||||
6 -> showStep(5) // gemini → features
|
||||
0 -> confirmExit()
|
||||
1 -> showStep(0) // back to language
|
||||
else -> showStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
@@ -236,18 +215,8 @@ class SetupActivity : AppCompatActivity() {
|
||||
stepServer = findViewById(R.id.stepServer)
|
||||
stepScale = findViewById(R.id.stepScale)
|
||||
stepScreensaver = findViewById(R.id.stepScreensaver)
|
||||
stepGemini = findViewById(R.id.stepGemini)
|
||||
stepBring = findViewById(R.id.stepBring)
|
||||
stepDone = findViewById(R.id.stepDone)
|
||||
|
||||
// Gemini + Bring fields
|
||||
setupGeminiKeyEdit = findViewById(R.id.setupGeminiKeyEdit)
|
||||
setupBringEmailEdit = findViewById(R.id.setupBringEmailEdit)
|
||||
setupBringPasswordEdit = findViewById(R.id.setupBringPasswordEdit)
|
||||
// Pre-fill from saved prefs
|
||||
(prefs.getString(KEY_GEMINI_KEY, "") ?: "").takeIf { it.isNotEmpty() }?.let { setupGeminiKeyEdit.setText(it) }
|
||||
(prefs.getString(KEY_BRING_EMAIL, "") ?: "").takeIf { it.isNotEmpty() }?.let { setupBringEmailEdit.setText(it) }
|
||||
|
||||
// Server step
|
||||
urlEdit = findViewById(R.id.setupUrlEdit)
|
||||
urlStatus = findViewById(R.id.setupUrlStatus)
|
||||
@@ -269,17 +238,10 @@ class SetupActivity : AppCompatActivity() {
|
||||
tvTestWeight = findViewById(R.id.tvTestWeight)
|
||||
testWeightBox = findViewById(R.id.testWeightBox)
|
||||
|
||||
// Features step — bind all four toggles
|
||||
// Screensaver step
|
||||
setupSwitchScreensaver = findViewById(R.id.setupSwitchScreensaver)
|
||||
setupSwitchPrices = findViewById(R.id.setupSwitchPrices)
|
||||
setupSwitchMealPlan = findViewById(R.id.setupSwitchMealPlan)
|
||||
setupSwitchZeroWaste = findViewById(R.id.setupSwitchZeroWaste)
|
||||
// Pre-fill from saved prefs only if each key was previously configured
|
||||
// ("se non sono impostati, chiedi!" — fresh install → all start at false)
|
||||
setupSwitchScreensaver.isChecked = if (prefs.contains(KEY_SCREENSAVER)) prefs.getBoolean(KEY_SCREENSAVER, false) else false
|
||||
setupSwitchPrices.isChecked = if (prefs.contains(KEY_PRICE_ENABLED)) prefs.getBoolean(KEY_PRICE_ENABLED, false) else false
|
||||
setupSwitchMealPlan.isChecked = if (prefs.contains(KEY_MEAL_PLAN)) prefs.getBoolean(KEY_MEAL_PLAN, false) else false
|
||||
setupSwitchZeroWaste.isChecked = if (prefs.contains(KEY_ZEROWASTE_TIPS)) prefs.getBoolean(KEY_ZEROWASTE_TIPS, false) else false
|
||||
// Pre-fill saved screensaver pref
|
||||
setupSwitchScreensaver.isChecked = prefs.getBoolean(KEY_SCREENSAVER, false)
|
||||
|
||||
// Done step
|
||||
summaryText = findViewById(R.id.setupSummaryText)
|
||||
@@ -298,8 +260,6 @@ class SetupActivity : AppCompatActivity() {
|
||||
findViewById<MaterialButton>(R.id.btnLangIt).setOnClickListener { selectLanguage("it") }
|
||||
findViewById<MaterialButton>(R.id.btnLangEn).setOnClickListener { selectLanguage("en") }
|
||||
findViewById<MaterialButton>(R.id.btnLangDe).setOnClickListener { selectLanguage("de") }
|
||||
findViewById<MaterialButton>(R.id.btnLangEs).setOnClickListener { selectLanguage("es") }
|
||||
findViewById<MaterialButton>(R.id.btnLangFr).setOnClickListener { selectLanguage("fr") }
|
||||
|
||||
// ── Welcome ──────────────────────────────────────────────────────
|
||||
findViewById<MaterialButton>(R.id.btnSetupExit).setOnClickListener { confirmExit() }
|
||||
@@ -400,13 +360,12 @@ class SetupActivity : AppCompatActivity() {
|
||||
scaleTestCard.visibility = View.GONE
|
||||
testWeightBox.visibility = View.GONE
|
||||
bleSetupCard.visibility = View.VISIBLE
|
||||
step3NextButtons.visibility = View.VISIBLE // restore nav buttons (back/next)
|
||||
tvSelectedScale.text = ""
|
||||
tvSelectedScale.visibility = View.GONE
|
||||
tvScanStatus.text = getString(R.string.ble_not_confirmed)
|
||||
tvScanStatus.text = "Bilancia non confermata. Riprova la scansione."
|
||||
tvScanStatus.setTextColor(0xFFfbbf24.toInt())
|
||||
btnScanBle.isEnabled = true
|
||||
btnScanBle.text = getString(R.string.ble_scan_again)
|
||||
btnScanBle.text = "🔍 Cerca bilancia"
|
||||
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = false
|
||||
}
|
||||
findViewById<MaterialButton>(R.id.btnTestSkip).setOnClickListener {
|
||||
@@ -422,38 +381,13 @@ class SetupActivity : AppCompatActivity() {
|
||||
findViewById<MaterialButton>(R.id.btnScaleNext).isEnabled = true
|
||||
}
|
||||
|
||||
// ── Features step (screensaver / prices / meal plan / zero-waste) ────
|
||||
// ── Screensaver ───────────────────────────────────────────────────
|
||||
findViewById<MaterialButton>(R.id.btnScreensaverBack).setOnClickListener { showStep(4) }
|
||||
findViewById<MaterialButton>(R.id.btnScreensaverNext).setOnClickListener {
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_SCREENSAVER, setupSwitchScreensaver.isChecked)
|
||||
.putBoolean(KEY_PRICE_ENABLED, setupSwitchPrices.isChecked)
|
||||
.putBoolean(KEY_MEAL_PLAN, setupSwitchMealPlan.isChecked)
|
||||
.putBoolean(KEY_ZEROWASTE_TIPS, setupSwitchZeroWaste.isChecked)
|
||||
.apply()
|
||||
prefs.edit().putBoolean(KEY_SCREENSAVER, setupSwitchScreensaver.isChecked).apply()
|
||||
showStep(6)
|
||||
}
|
||||
|
||||
// ── Gemini step ───────────────────────────────────────────────────
|
||||
findViewById<MaterialButton>(R.id.btnGeminiBack).setOnClickListener { showStep(5) }
|
||||
findViewById<MaterialButton>(R.id.btnGeminiSkip).setOnClickListener { showStep(7) }
|
||||
findViewById<MaterialButton>(R.id.btnGeminiNext).setOnClickListener {
|
||||
val key = setupGeminiKeyEdit.text.toString().trim()
|
||||
if (key.isNotEmpty()) prefs.edit().putString(KEY_GEMINI_KEY, key).apply()
|
||||
showStep(7)
|
||||
}
|
||||
|
||||
// ── Bring step ────────────────────────────────────────────────────
|
||||
findViewById<MaterialButton>(R.id.btnBringBack).setOnClickListener { showStep(6) }
|
||||
findViewById<MaterialButton>(R.id.btnBringSkip).setOnClickListener { showStep(8) }
|
||||
findViewById<MaterialButton>(R.id.btnBringNext).setOnClickListener {
|
||||
val email = setupBringEmailEdit.text.toString().trim()
|
||||
val pass = setupBringPasswordEdit.text.toString().trim()
|
||||
if (email.isNotEmpty()) prefs.edit().putString(KEY_BRING_EMAIL, email).apply()
|
||||
if (pass.isNotEmpty()) prefs.edit().putString(KEY_BRING_PASSWORD, pass).apply()
|
||||
showStep(8)
|
||||
}
|
||||
|
||||
// ── Done ──────────────────────────────────────────────────────────
|
||||
findViewById<MaterialButton>(R.id.btnLaunch).setOnClickListener { finishSetup() }
|
||||
}
|
||||
@@ -469,27 +403,20 @@ class SetupActivity : AppCompatActivity() {
|
||||
|
||||
private fun highlightSelectedLang() {
|
||||
val saved = prefs.getString(KEY_LANGUAGE, null) ?: return
|
||||
val btnIt = findViewById<MaterialButton>(R.id.btnLangIt)
|
||||
val btnEn = findViewById<MaterialButton>(R.id.btnLangEn)
|
||||
val btnDe = findViewById<MaterialButton>(R.id.btnLangDe)
|
||||
val btnEs = findViewById<MaterialButton>(R.id.btnLangEs)
|
||||
val btnFr = findViewById<MaterialButton>(R.id.btnLangFr)
|
||||
val (btnIt, btnEn, btnDe) = Triple(
|
||||
findViewById<MaterialButton>(R.id.btnLangIt),
|
||||
findViewById<MaterialButton>(R.id.btnLangEn),
|
||||
findViewById<MaterialButton>(R.id.btnLangDe)
|
||||
)
|
||||
// Add checkmark to selected
|
||||
btnIt.text = if (saved == "it") "✅ 🇮🇹 Italiano" else "🇮🇹 Italiano"
|
||||
btnEn.text = if (saved == "en") "✅ 🇬🇧 English" else "🇬🇧 English"
|
||||
btnDe.text = if (saved == "de") "✅ 🇩🇪 Deutsch" else "🇩🇪 Deutsch"
|
||||
btnEs.text = if (saved == "es") "✅ 🇪🇸 Español" else "🇪🇸 Español"
|
||||
btnFr.text = if (saved == "fr") "✅ 🇫🇷 Français" else "🇫🇷 Français"
|
||||
}
|
||||
|
||||
// ── Step navigation ───────────────────────────────────────────────────
|
||||
|
||||
private fun showStep(step: Int) {
|
||||
// Auto-skip Gemini step if already configured
|
||||
if (step == 6 && !(prefs.getString(KEY_GEMINI_KEY, "") ?: "").isNullOrEmpty()) { showStep(7); return }
|
||||
// Auto-skip Bring step if already configured
|
||||
if (step == 7 && !(prefs.getString(KEY_BRING_EMAIL, "") ?: "").isNullOrEmpty()) { showStep(8); return }
|
||||
|
||||
currentStep = step
|
||||
stepLanguage.visibility = if (step == 0) View.VISIBLE else View.GONE
|
||||
stepWelcome.visibility = if (step == 1) View.VISIBLE else View.GONE
|
||||
@@ -497,9 +424,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
stepServer.visibility = if (step == 3) View.VISIBLE else View.GONE
|
||||
stepScale.visibility = if (step == 4) View.VISIBLE else View.GONE
|
||||
stepScreensaver.visibility = if (step == 5) View.VISIBLE else View.GONE
|
||||
stepGemini.visibility = if (step == 6) View.VISIBLE else View.GONE
|
||||
stepBring.visibility = if (step == 7) View.VISIBLE else View.GONE
|
||||
stepDone.visibility = if (step == 8) View.VISIBLE else View.GONE
|
||||
stepDone.visibility = if (step == 6) View.VISIBLE else View.GONE
|
||||
|
||||
updateProgressDots()
|
||||
|
||||
@@ -535,27 +460,22 @@ class SetupActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
// Build summary when entering done step
|
||||
if (step == 8) buildSummary()
|
||||
if (step == 6) buildSummary()
|
||||
|
||||
// Cancel auto-discover when leaving server step
|
||||
if (step != 3) discoverCancelled.set(true)
|
||||
|
||||
// Auto-discover when entering server step (empty URL only)
|
||||
if (step == 3 && urlEdit.text.toString().trim().isEmpty()) {
|
||||
autoDiscover()
|
||||
}
|
||||
|
||||
// Scroll to top
|
||||
try { findViewById<ScrollView>(R.id.setupScrollView).scrollTo(0, 0) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun updateProgressDots() {
|
||||
progressDots.removeAllViews()
|
||||
// Show 7 dots for steps 1-7; step 0 (language) and step 8 (done) have no dots
|
||||
if (currentStep == 0 || currentStep == 8) return
|
||||
val active = currentStep // 1..7
|
||||
// Show 5 dots for steps 1-5; step 0 (language) and step 6 (done) have no dots
|
||||
if (currentStep == 0 || currentStep == 6) return
|
||||
val active = currentStep // 1..5
|
||||
val density = resources.displayMetrics.density
|
||||
for (i in 1..7) {
|
||||
for (i in 1..5) {
|
||||
val dot = View(this)
|
||||
val sizeDp = if (i == active) 10 else 7
|
||||
val px = (sizeDp * density).toInt()
|
||||
@@ -702,58 +622,6 @@ class SetupActivity : AppCompatActivity() {
|
||||
})
|
||||
}
|
||||
|
||||
private fun normalizeDiscoveredBase(urlStr: String): String {
|
||||
var base = urlStr.substringBefore("/api/")
|
||||
if (base.endsWith(":443")) base = base.removeSuffix(":443")
|
||||
if (base.endsWith(":80")) base = base.removeSuffix(":80")
|
||||
return if (base.endsWith("/")) base else "$base/"
|
||||
}
|
||||
|
||||
private fun probeEverShelfEndpoint(urlStr: String): String? {
|
||||
return try {
|
||||
val conn = openConn(urlStr) ?: return null
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..399) {
|
||||
conn.disconnect()
|
||||
return null
|
||||
}
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
conn.disconnect()
|
||||
if (body.contains("gemini_key_set") || body.contains("\"success\"") || body.contains("\"ok\"")) {
|
||||
normalizeDiscoveredBase(urlStr)
|
||||
} else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun probeEverShelfHost(ip: String, port: Int): String? {
|
||||
val reachable = try {
|
||||
Socket().use { s -> s.connect(InetSocketAddress(ip, port), 800); true }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!reachable) return null
|
||||
|
||||
val scheme = if (port == 443 || port == 8443) "https" else "http"
|
||||
val portInUrl = when {
|
||||
scheme == "https" && port == 443 -> ""
|
||||
scheme == "http" && port == 80 -> ""
|
||||
else -> ":$port"
|
||||
}
|
||||
val paths = listOf(
|
||||
"/dispensa/api/index.php?action=ping",
|
||||
"/api/index.php?action=ping",
|
||||
"/dispensa/api/index.php?action=get_settings",
|
||||
"/api/index.php?action=get_settings",
|
||||
"/evershelf/api/index.php?action=get_settings",
|
||||
)
|
||||
for (path in paths) {
|
||||
probeEverShelfEndpoint("$scheme://$ip$portInUrl$path")?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun openConn(urlStr: String): HttpURLConnection? {
|
||||
return try {
|
||||
val conn = URL(urlStr).openConnection()
|
||||
@@ -829,52 +697,9 @@ class SetupActivity : AppCompatActivity() {
|
||||
runOnUiThread { discoverStatus.text = "📡 $detectedLabel" }
|
||||
|
||||
val ports = listOf(443, 80, 8080, 8443)
|
||||
|
||||
// ── 1b. Fast path: likely hosts on Wi-Fi subnet (incl. .128) before full sweep ─
|
||||
val priorityIps = linkedSetOf<String>()
|
||||
try {
|
||||
val ifaces = NetworkInterface.getNetworkInterfaces()
|
||||
while (ifaces != null && ifaces.hasMoreElements()) {
|
||||
val intf = ifaces.nextElement()
|
||||
if (!intf.isUp || intf.isLoopback) continue
|
||||
for (addr in intf.interfaceAddresses) {
|
||||
val ip = addr.address
|
||||
if (ip is java.net.Inet4Address && !ip.isLoopbackAddress) {
|
||||
priorityIps.add(ip.hostAddress ?: continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
for (subnet in wifiSubnets.ifEmpty { subnets.take(1) }) {
|
||||
for (last in listOf(1, 128, 100, 10, 50, 254)) {
|
||||
priorityIps.add("$subnet.$last")
|
||||
}
|
||||
}
|
||||
|
||||
runOnUiThread { discoverStatus.text = "🔍 ${getString(R.string.setup_discovering_detail)}" }
|
||||
for (ip in priorityIps) {
|
||||
if (discoverCancelled.get()) break
|
||||
for (port in ports) {
|
||||
val hit = probeEverShelfHost(ip, port)
|
||||
if (hit != null) {
|
||||
runOnUiThread {
|
||||
urlEdit.setText(hit)
|
||||
discoverStatus.text = "✅ ${getString(R.string.setup_server_found)}: $hit"
|
||||
discoverStatus.setTextColor(0xFF34d399.toInt())
|
||||
showUrlStatus("✅ ${getString(R.string.setup_server_found)}", true)
|
||||
btnDiscover.isEnabled = true
|
||||
btnDiscover.text = getString(R.string.setup_discover_btn)
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val paths = listOf(
|
||||
"/dispensa/api/index.php?action=ping",
|
||||
"/api/index.php?action=ping",
|
||||
"/dispensa/api/index.php?action=get_settings",
|
||||
"/api/index.php?action=get_settings",
|
||||
"/dispensa/api/index.php?action=get_settings",
|
||||
"/evershelf/api/index.php?action=get_settings",
|
||||
)
|
||||
|
||||
@@ -919,24 +744,30 @@ class SetupActivity : AppCompatActivity() {
|
||||
|
||||
// Full HTTP probe on reachable host
|
||||
val scheme = if (port == 443 || port == 8443) "https" else "http"
|
||||
val portInUrl = when {
|
||||
scheme == "https" && port == 443 -> ""
|
||||
scheme == "http" && port == 80 -> ""
|
||||
else -> ":$port"
|
||||
}
|
||||
for (path in paths) {
|
||||
if (discoverCancelled.get() || found.get()) break
|
||||
probeEverShelfEndpoint("$scheme://$ip$portInUrl$path")?.let { return@submit it }
|
||||
val urlStr = "$scheme://$ip:$port$path"
|
||||
try {
|
||||
val conn = openConn(urlStr) ?: continue
|
||||
val code = conn.responseCode
|
||||
if (code in 200..399) {
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
conn.disconnect()
|
||||
if (body.contains("gemini_key_set") || body.contains("\"success\"")) {
|
||||
return@submit urlStr.substringBefore("/api/") + "/"
|
||||
}
|
||||
} else conn.disconnect()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Collect results until all tasks finish or a server is found ────
|
||||
// ── 3. Collect results as they complete (not in submission order) ────
|
||||
var result: String? = null
|
||||
var collected = 0
|
||||
while (collected < total && !discoverCancelled.get() && result == null) {
|
||||
val future = cs.poll(500, TimeUnit.MILLISECONDS) ?: continue
|
||||
while (collected < total && !discoverCancelled.get()) {
|
||||
val future = cs.poll(3, TimeUnit.SECONDS) ?: break
|
||||
collected++
|
||||
val r = try { future.get() } catch (_: Exception) { null }
|
||||
if (r != null && found.compareAndSet(false, true)) {
|
||||
@@ -988,7 +819,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
}
|
||||
discoveredDevices.clear()
|
||||
deviceAdapter?.notifyDataSetChanged()
|
||||
tvScanStatus.text = getString(R.string.ble_scanning)
|
||||
tvScanStatus.text = "🔍 Scansione in corso…"
|
||||
tvScanStatus.setTextColor(0xFF94a3b8.toInt())
|
||||
btnScanBle.isEnabled = false
|
||||
mgr.startScan()
|
||||
@@ -1001,7 +832,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
tvSelectedScale.text = "✅ ${info.name}"
|
||||
tvSelectedScale.visibility = View.VISIBLE
|
||||
btnScanBle.isEnabled = true
|
||||
btnScanBle.text = getString(R.string.ble_scan_again)
|
||||
btnScanBle.text = "🔄 Scansiona di nuovo"
|
||||
// Start connection test
|
||||
startScaleTest(info)
|
||||
}
|
||||
@@ -1014,7 +845,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
scaleTestCard.visibility = View.VISIBLE
|
||||
testWeightBox.visibility = View.GONE
|
||||
step3NextButtons.visibility = View.GONE
|
||||
tvTestStatus.text = getString(R.string.ble_connecting_to).format(info.name)
|
||||
tvTestStatus.text = "🔗 Connessione a ${info.name}…"
|
||||
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
|
||||
tvTestWeight.text = "— g"
|
||||
// Disable confirm/retry until we have data
|
||||
@@ -1038,25 +869,23 @@ class SetupActivity : AppCompatActivity() {
|
||||
}
|
||||
override fun onConnecting(device: BluetoothDevice) {
|
||||
if (!isInTestMode) return
|
||||
tvTestStatus.text = getString(R.string.ble_connecting)
|
||||
tvTestStatus.text = "🔗 Connessione in corso…"
|
||||
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
|
||||
}
|
||||
override fun onConnected(deviceName: String) {
|
||||
if (!isInTestMode) return
|
||||
tvTestStatus.text = getString(R.string.ble_connected)
|
||||
tvTestStatus.text = "⚖️ Connesso! Posiziona un oggetto sulla bilancia…"
|
||||
tvTestStatus.setTextColor(0xFF34d399.toInt())
|
||||
testWeightBox.visibility = View.VISIBLE
|
||||
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
|
||||
}
|
||||
override fun onDisconnected() {
|
||||
if (!isInTestMode) return
|
||||
tvTestStatus.text = getString(R.string.ble_disconnected)
|
||||
tvTestStatus.text = "⚠️ Connessione persa. Riprova."
|
||||
tvTestStatus.setTextColor(0xFFfbbf24.toInt())
|
||||
testWeightBox.visibility = View.GONE
|
||||
testHasWeight = false
|
||||
findViewById<MaterialButton>(R.id.btnTestConfirm).isEnabled = false
|
||||
// Always re-enable retry so the user is never stuck
|
||||
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
|
||||
}
|
||||
override fun onWeightReceived(reading: WeightReading) {
|
||||
if (!isInTestMode) return
|
||||
@@ -1067,7 +896,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
"%g ${reading.unit}".format(reading.value)
|
||||
tvTestWeight.text = display
|
||||
testWeightBox.visibility = View.VISIBLE
|
||||
tvTestStatus.text = getString(R.string.ble_weight_received)
|
||||
tvTestStatus.text = "Peso ricevuto — coincide con quello sulla bilancia?"
|
||||
tvTestStatus.setTextColor(0xFF94a3b8.toInt())
|
||||
findViewById<MaterialButton>(R.id.btnTestConfirm).isEnabled = true
|
||||
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
|
||||
@@ -1089,10 +918,10 @@ class SetupActivity : AppCompatActivity() {
|
||||
override fun onScanStopped() {
|
||||
btnScanBle.isEnabled = true
|
||||
if (discoveredDevices.isEmpty()) {
|
||||
tvScanStatus.text = getString(R.string.ble_no_scale_found)
|
||||
tvScanStatus.text = "Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova."
|
||||
tvScanStatus.setTextColor(0xFFfbbf24.toInt())
|
||||
} else {
|
||||
tvScanStatus.text = getString(R.string.ble_select_from_list)
|
||||
tvScanStatus.text = "Seleziona la tua bilancia dall'elenco."
|
||||
tvScanStatus.setTextColor(0xFF94a3b8.toInt())
|
||||
}
|
||||
}
|
||||
@@ -1142,32 +971,22 @@ class SetupActivity : AppCompatActivity() {
|
||||
// ── Summary / Finish ─────────────────────────────────────────────────
|
||||
|
||||
private fun buildSummary() {
|
||||
val url = prefs.getString(KEY_URL, "") ?: ""
|
||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
|
||||
val screensOn = setupSwitchScreensaver.isChecked
|
||||
val pricesOn = setupSwitchPrices.isChecked
|
||||
val mealPlanOn = setupSwitchMealPlan.isChecked
|
||||
val zeroWasteOn = setupSwitchZeroWaste.isChecked
|
||||
val scaleName = bleManager?.getSavedDeviceName()
|
||||
val scaleOk = hasScale && scaleName != null
|
||||
val lang = prefs.getString(KEY_LANGUAGE, "it") ?: "it"
|
||||
val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; "es" -> "Español 🇪🇸"; "fr" -> "Français 🇫🇷"; else -> "Italiano 🇮🇹" }
|
||||
val url = prefs.getString(KEY_URL, "") ?: ""
|
||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false)
|
||||
val screensOn = setupSwitchScreensaver.isChecked
|
||||
val scaleName = bleManager?.getSavedDeviceName()
|
||||
val scaleOk = hasScale && scaleName != null
|
||||
val lang = prefs.getString(KEY_LANGUAGE, "it") ?: "it"
|
||||
val langLabel = when (lang) { "en" -> "English 🇬🇧"; "de" -> "Deutsch 🇩🇪"; else -> "Italiano 🇮🇹" }
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("🌐 ${getString(R.string.summary_lang)}: $langLabel")
|
||||
if (url.isNotEmpty()) sb.appendLine("🖥️ Server: $url")
|
||||
sb.appendLine(when {
|
||||
scaleOk -> getString(R.string.summary_scale_ok).format(scaleName)
|
||||
hasScale -> "⚠️ ${getString(R.string.summary_scale_warn)}"
|
||||
scaleOk -> "✅ Bilancia: $scaleName"
|
||||
hasScale -> "⚠️ Bilancia: da configurare"
|
||||
else -> "⏭ ${getString(R.string.summary_scale_skip)}"
|
||||
})
|
||||
sb.appendLine(if (screensOn) getString(R.string.summary_screensaver_on) else getString(R.string.summary_screensaver_off))
|
||||
if (pricesOn) sb.appendLine(getString(R.string.summary_prices_on))
|
||||
if (mealPlanOn) sb.appendLine(getString(R.string.summary_mealplan_on))
|
||||
if (zeroWasteOn) sb.appendLine(getString(R.string.summary_zerowaste_on))
|
||||
val geminiSet = !(prefs.getString(KEY_GEMINI_KEY, "") ?: "").isNullOrEmpty()
|
||||
val bringSet = !(prefs.getString(KEY_BRING_EMAIL, "") ?: "").isNullOrEmpty()
|
||||
sb.appendLine(if (geminiSet) getString(R.string.summary_gemini_set) else getString(R.string.summary_gemini_skip))
|
||||
sb.appendLine(if (bringSet) getString(R.string.summary_bring_set) else getString(R.string.summary_bring_skip))
|
||||
sb.appendLine(if (screensOn) "🌙 ${getString(R.string.summary_screensaver_on)}" else "💡 ${getString(R.string.summary_screensaver_off)}")
|
||||
summaryText.text = sb.toString().trimEnd()
|
||||
}
|
||||
|
||||
@@ -1175,29 +994,19 @@ class SetupActivity : AppCompatActivity() {
|
||||
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
|
||||
val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trimEnd('/')
|
||||
if (baseUrl.isNotEmpty()) {
|
||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false) && (bleManager?.getSavedDeviceAddress() != null)
|
||||
val screensaver = prefs.getBoolean(KEY_SCREENSAVER, false)
|
||||
val priceEnabled = prefs.getBoolean(KEY_PRICE_ENABLED, false)
|
||||
val mealPlan = prefs.getBoolean(KEY_MEAL_PLAN, false)
|
||||
val zeroWaste = prefs.getBoolean(KEY_ZEROWASTE_TIPS, false)
|
||||
val hasScale = prefs.getBoolean(KEY_HAS_SCALE, false) && (bleManager?.getSavedDeviceAddress() != null)
|
||||
val screensaver = prefs.getBoolean(KEY_SCREENSAVER, false)
|
||||
Thread {
|
||||
try {
|
||||
val url = "$baseUrl/api/index.php?action=save_settings"
|
||||
val geminiKey = prefs.getString(KEY_GEMINI_KEY, "") ?: ""
|
||||
val bringEmail = prefs.getString(KEY_BRING_EMAIL, "") ?: ""
|
||||
val bringPassword = prefs.getString(KEY_BRING_PASSWORD, "") ?: ""
|
||||
val body = buildString {
|
||||
append("{\"screensaver_enabled\":$screensaver")
|
||||
append(",\"price_enabled\":$priceEnabled")
|
||||
append(",\"meal_plan_enabled\":$mealPlan")
|
||||
append(",\"zerowaste_tips_enabled\":$zeroWaste")
|
||||
if (hasScale) {
|
||||
// Use the tablet's actual LAN IP so the EverShelf server
|
||||
// (potentially on a different machine) can reach the gateway.
|
||||
val lanIp = getDeviceLanIp() ?: "127.0.0.1"
|
||||
append(",\"scale_enabled\":true,\"scale_gateway_url\":\"ws://$lanIp:8765\"")
|
||||
}
|
||||
if (geminiKey.isNotEmpty()) append(",\"gemini_api_key\":\"${geminiKey.replace("\"", "\\\"")}\"")
|
||||
if (bringEmail.isNotEmpty()) append(",\"bring_email\":\"${bringEmail.replace("\"", "\\\"")}\"")
|
||||
if (bringPassword.isNotEmpty()) append(",\"bring_password\":\"${bringPassword.replace("\"", "\\\"")}\"")
|
||||
append("}")
|
||||
}
|
||||
val conn = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
|
||||
|
||||
@@ -43,18 +43,17 @@
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Settings gear (shown after setup, over WebView) — bottom-right corner so it never
|
||||
overlaps the webapp header buttons (e.g. the 📷 scan button at top-right) -->
|
||||
<!-- 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="bottom|end"
|
||||
android:layout_marginBottom="80dp"
|
||||
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.28"
|
||||
android:alpha="0.12"
|
||||
android:contentDescription="Settings"
|
||||
android:scaleType="centerInside"
|
||||
android:visibility="gone" />
|
||||
|
||||
@@ -224,43 +224,6 @@
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Advanced / App Settings link -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="IMPOSTAZIONI AVANZATE"
|
||||
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="Home Assistant, Gemini AI, Bring!, TTS, notifiche e tutte le altre funzionalità si configurano direttamente nell'app EverShelf."
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="13sp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnOpenAppSettings"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:text="← Torna all'app per le impostazioni avanzate"
|
||||
android:textSize="13sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#7c3aed" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -78,11 +78,11 @@
|
||||
android:layout_marginBottom="24dp"
|
||||
android:contentDescription="EverShelf" />
|
||||
|
||||
<!-- Title shown in all 5 languages so it's always readable -->
|
||||
<!-- Title shown in all 3 languages so it's always readable -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Scegli la lingua · Choose your language\nSprache wählen · Elige el idioma\nChoisissez votre langue"
|
||||
android:text="Scegli la lingua\nChoose your language\nSprache wählen"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
@@ -117,27 +117,7 @@
|
||||
android:text="🇩🇪 Deutsch"
|
||||
android:textSize="18sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#b91c1c"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnLangEs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:text="🇪🇸 Español"
|
||||
android:textSize="18sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#c2410c"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnLangFr"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:text="🇫🇷 Français"
|
||||
android:textSize="18sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#1d4ed8" />
|
||||
android:backgroundTint="#b91c1c" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -1070,7 +1050,7 @@
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ════════════════════════════════════════════
|
||||
STEP 5 — Features
|
||||
STEP 5 — Screensaver
|
||||
════════════════════════════════════════════ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/stepScreensaver"
|
||||
@@ -1083,58 +1063,66 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⚡"
|
||||
android:text="🌙"
|
||||
android:textSize="52sp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_features_title"
|
||||
android:id="@+id/tvScreensaverTitle"
|
||||
android:text="@string/setup_screensaver_title"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvScreensaverDesc"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_features_desc"
|
||||
android:text="Dopo 5 minuti di inattività mostra un overlay con l'orologio e informazioni utili (statistiche, piano pasti). Lo schermo rimane SEMPRE acceso — questa opzione riguarda solo l'overlay visivo in-app."
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="15sp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:layout_marginBottom="20dp" />
|
||||
android:layout_marginBottom="28dp" />
|
||||
|
||||
<!-- Toggle: Screensaver -->
|
||||
<!-- Toggle card -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:padding="20dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
android:layout_marginBottom="32dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvScreensaverToggleLabel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_screensaver_toggle_label"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="15sp"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="3dp" />
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvScreensaverToggleHint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_screensaver_toggle_hint"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="12sp" />
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/setupSwitchScreensaver"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -1142,114 +1130,6 @@
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Toggle: Prezzi lista spesa -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_prices_toggle_label"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="3dp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_prices_toggle_hint"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/setupSwitchPrices"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Toggle: Piano pasti -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_mealplan_toggle_label"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="3dp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_mealplan_toggle_hint"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/setupSwitchMealPlan"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Toggle: Suggerimenti zero-waste -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="24dp">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_zerowaste_toggle_label"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="3dp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_zerowaste_toggle_hint"
|
||||
android:textColor="#64748b"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/setupSwitchZeroWaste"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="false" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Navigation -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -1261,7 +1141,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/setup_step_back"
|
||||
android:text="← Indietro"
|
||||
android:textSize="14sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
@@ -1274,7 +1154,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="2"
|
||||
android:text="@string/setup_step_next"
|
||||
android:text="Avanti →"
|
||||
android:textSize="15sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#7c3aed" />
|
||||
@@ -1282,230 +1162,7 @@
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ════════════════════════════════════════════
|
||||
STEP 6 — Gemini AI key
|
||||
════════════════════════════════════════════ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/stepGemini"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🤖"
|
||||
android:textSize="52sp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_gemini_title"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_gemini_desc"
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="15sp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- How-to card -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="14dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="16dp">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="💡"
|
||||
android:textSize="20sp"
|
||||
android:layout_marginEnd="10dp" />
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_gemini_how"
|
||||
android:textColor="#7dd3fc"
|
||||
android:textSize="13sp"
|
||||
android:lineSpacingExtra="3dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/setupGeminiKeyEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/setup_gemini_hint"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textColorHint="#475569"
|
||||
android:backgroundTint="#334155"
|
||||
android:inputType="textVisiblePassword"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<!-- Navigation -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnGeminiBack"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/setup_step_back"
|
||||
android:textSize="14sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:strokeColor="#334155"
|
||||
android:textColor="#64748b"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnGeminiSkip"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/setup_skip_later"
|
||||
android:textSize="13sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:strokeColor="#475569"
|
||||
android:textColor="#94a3b8"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnGeminiNext"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/setup_confirm"
|
||||
android:textSize="14sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#7c3aed" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ════════════════════════════════════════════
|
||||
STEP 7 — Bring! credentials
|
||||
════════════════════════════════════════════ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/stepBring"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🛒"
|
||||
android:textSize="52sp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_bring_title"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_bring_desc"
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="15sp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/setupBringEmailEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/setup_bring_email_hint"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textColorHint="#475569"
|
||||
android:backgroundTint="#334155"
|
||||
android:inputType="textEmailAddress"
|
||||
android:textSize="15sp"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/setupBringPasswordEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/setup_bring_pass_hint"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textColorHint="#475569"
|
||||
android:backgroundTint="#334155"
|
||||
android:inputType="textPassword"
|
||||
android:textSize="15sp"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<!-- Navigation -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnBringBack"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/setup_step_back"
|
||||
android:textSize="14sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:strokeColor="#334155"
|
||||
android:textColor="#64748b"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnBringSkip"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/setup_skip_later"
|
||||
android:textSize="13sp"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:strokeColor="#475569"
|
||||
android:textColor="#94a3b8"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnBringNext"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/setup_confirm"
|
||||
android:textSize="14sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#059669" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ════════════════════════════════════════════
|
||||
STEP 8 — Done
|
||||
STEP 6 — Done
|
||||
════════════════════════════════════════════ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/stepDone"
|
||||
@@ -1525,7 +1182,7 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_done_title"
|
||||
android:text="Tutto pronto!"
|
||||
android:textColor="#f1f5f9"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
@@ -1534,7 +1191,7 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_done_desc"
|
||||
android:text="La configurazione è completa. Premi il pulsante per avviare EverShelf in modalità kiosk."
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="15sp"
|
||||
android:gravity="center"
|
||||
@@ -1553,10 +1210,10 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/setup_done_summary_label"
|
||||
android:text="Riepilogo configurazione"
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="13sp"
|
||||
android:textAllCaps="false"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.08"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
@@ -1574,7 +1231,7 @@
|
||||
android:id="@+id/btnLaunch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp"
|
||||
android:text="@string/btn_launch"
|
||||
android:text="🚀 Avvia EverShelf"
|
||||
android:textSize="18sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#059669" />
|
||||
|
||||
@@ -1,37 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
|
||||
<!-- Setup-Assistent Zeichenfolgen -->
|
||||
<string name="setup_enter_url">Bitte zuerst eine URL eingeben</string>
|
||||
<string name="setup_testing">Verbindung wird getestet…</string>
|
||||
<string name="setup_server_found">EverShelf-Server gefunden und API aktiv!</string>
|
||||
<string name="setup_api_not_found">Server erreichbar, aber EverShelf-API nicht gefunden. Pfad prüfen.</string>
|
||||
<string name="setup_unreachable">Server nicht erreichbar</string>
|
||||
<string name="setup_discover_btn">🔍 Lokales Netzwerk durchsuchen</string>
|
||||
<string name="setup_perms_granted_next">✅ Berechtigungen erteilt — Weiter →</string>
|
||||
<string name="setup_discovering">Suche läuft…</string>
|
||||
<string name="setup_discover_btn">🔍 Lokales Netzwerk durchsuchen</string> <string name="setup_perms_granted_next">✅ Berechtigungen erteilt — Weiter →</string> <string name="setup_discovering">Suche läuft…</string>
|
||||
<string name="setup_discovering_detail">Suche nach EverShelf-Servern im lokalen Netzwerk…</string>
|
||||
<string name="setup_discover_not_found">Kein EverShelf-Server automatisch gefunden. URL manuell eingeben.</string>
|
||||
<string name="setup_exit_title">Setup beenden?</string>
|
||||
<string name="setup_exit_message">Die Einrichtung kann später beim erneuten Öffnen der App abgeschlossen werden.</string>
|
||||
<string name="setup_exit_confirm">Beenden</string>
|
||||
<string name="setup_exit_cancel">Weiter</string>
|
||||
<string name="setup_step_back">← Zurück</string>
|
||||
<string name="setup_step_next">Weiter →</string>
|
||||
<string name="setup_skip_later">Später einrichten</string>
|
||||
<string name="setup_confirm">Bestätigen →</string>
|
||||
|
||||
<!-- Wizard Schritt 3: Smart-Waage -->
|
||||
<string name="wizard_step3_title">Smart-Waage (Optional)</string>
|
||||
<string name="wizard_step3_description">Um eine Bluetooth-Küchenwaage zu verwenden, musst du die EverShelf Scale Gateway App separat installieren.</string>
|
||||
<string name="wizard_step3_question">Hast du eine Bluetooth-Küchenwaage?</string>
|
||||
<string name="wizard_step3_yes">✅ Ja, ich habe eine Waage</string>
|
||||
<string name="wizard_step3_no">➡️ Nein, überspringen</string>
|
||||
<string name="ble_scanning">🔍 Suche läuft…</string>
|
||||
<string name="ble_connected">Verbunden! Gegenstand auf die Waage legen…</string>
|
||||
<string name="ble_disconnected">Verbindung getrennt. Erneut versuchen.</string>
|
||||
<string name="ble_no_scale_found">Keine Waage gefunden. Sicherstellen, dass sie eingeschaltet und in der Nähe ist, und erneut versuchen.</string>
|
||||
<string name="ble_select_from_list">Waage aus der Liste auswählen.</string>
|
||||
<string name="ble_not_confirmed">Waage nicht bestätigt. Erneut scannen.</string>
|
||||
<string name="ble_scan_again">🔄 Erneut scannen</string>
|
||||
<string name="ble_weight_received">Gewicht empfangen — Stimmt es mit der Anzeige überein?</string>
|
||||
|
||||
<!-- Gateway-Statusmeldungen -->
|
||||
<string name="wizard_gateway_installed">Scale Gateway installiert ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">Wird beim Fortfahren im Hintergrund gestartet.</string>
|
||||
<string name="wizard_gateway_not_installed">Scale Gateway nicht installiert</string>
|
||||
@@ -40,6 +32,8 @@
|
||||
<string name="wizard_gateway_up_to_date">Scale Gateway ist aktuell.</string>
|
||||
<string name="wizard_gateway_update_available">Update für Scale Gateway verfügbar</string>
|
||||
<string name="wizard_gateway_update_detail">Tippe auf den Button, um jetzt zu aktualisieren.</string>
|
||||
|
||||
<!-- Download- / Installationsfortschritt -->
|
||||
<string name="install_downloading">Download läuft…</string>
|
||||
<string name="install_downloading_detail">Bitte warten, die Datei wird heruntergeladen.</string>
|
||||
<string name="install_installing">Installation läuft…</string>
|
||||
@@ -51,50 +45,29 @@
|
||||
<string name="install_error_install">Installation fehlgeschlagen</string>
|
||||
<string name="install_perm_detail">Aktiviere \'Unbekannte Apps installieren\' in den Einstellungen, dann komm zurück.</string>
|
||||
<string name="install_btn_retry">↩ Nochmal versuchen</string>
|
||||
|
||||
<!-- Schaltflächen -->
|
||||
<string name="btn_back">Zurück</string>
|
||||
<string name="btn_launch">🚀 EverShelf starten</string>
|
||||
<string name="btn_launch_no_scale">🚀 Ohne Waage starten</string>
|
||||
<string name="btn_download_gateway">📥 Scale Gateway installieren</string>
|
||||
<string name="btn_update_gateway">📥 Scale Gateway aktualisieren</string>
|
||||
|
||||
<!-- Server-Erreichbarkeit prüfen (Wizard Schritt 3) -->
|
||||
<string name="wizard_server_checking">Server-Verbindung wird geprüft…</string>
|
||||
<string name="wizard_server_ok">Server erreichbar ✅</string>
|
||||
<string name="wizard_server_ok_detail">Fehlerberichterstattung aktiv — Installationsfehler werden automatisch an GitHub Issues gesendet.</string>
|
||||
<string name="wizard_server_error">Server nicht erreichbar ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Fehler werden GitHub Issues nicht erreichen. URL in Schritt 2 prüfen.</string>
|
||||
<string name="setup_features_title">Funktionen</string>
|
||||
<string name="setup_features_desc">Aktiviere die gewünschten Funktionen. Du kannst sie später jederzeit in den Servereinstellungen ändern.</string>
|
||||
<string name="setup_screensaver_toggle_label">Uhr-Bildschirmschoner</string>
|
||||
<string name="setup_screensaver_toggle_hint">Zeigt eine Uhranzeige nach 5 Min. Inaktivität.</string>
|
||||
<string name="setup_prices_toggle_label">Einkaufslisten-Preise</string>
|
||||
<string name="setup_prices_toggle_hint">KI-gestützte automatische Kostensätzung für jeden Artikel.</string>
|
||||
<string name="setup_mealplan_toggle_label">Mahlzeitenplan</string>
|
||||
<string name="setup_mealplan_toggle_hint">Plane die Wöchentliche Mahlzeiten mit Rezepten aus deiner Vorratskammer.</string>
|
||||
<string name="setup_zerowaste_toggle_label">Zero-Waste-Tipps</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Beim Kochen Tipps zur Wiederverwendung von Resten anzeigen (Schalen, Kochwasser usw.).</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf nutzt Google Gemini AI für Rezeptvorschläge, smarte Einkaufsschätzungen und mehr.\n\nZum Aktivieren den kostenlosen Gemini API-Schlüssel eingeben.</string>
|
||||
<string name="setup_gemini_how">Kostenlosen Schlüssel unter: aistudio.google.com → \"API-Schlüssel erhalten\"</string>
|
||||
<string name="setup_gemini_hint">API-Schlüssel einfügen (beginnt mit AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Einkaufsliste</string>
|
||||
<string name="setup_bring_desc">EverShelf kann die Einkaufsliste mit der Bring!-App synchronisieren.\n\nBring!-Zugangsdaten eingeben, um die Integration zu aktivieren.</string>
|
||||
<string name="setup_bring_email_hint">Bring!-E-Mail-Adresse</string>
|
||||
<string name="setup_bring_pass_hint">Bring!-Passwort</string>
|
||||
<string name="setup_done_title">Alles bereit!</string>
|
||||
<string name="setup_done_desc">Die Einrichtung ist abgeschlossen. Auf den Button tippen, um EverShelf im Kiosk-Modus zu starten.</string>
|
||||
<string name="setup_done_summary_label">KONFIGURATIONSSÜBERSICHT</string>
|
||||
<!-- Bildschirmschoner-Schritt -->
|
||||
<string name="setup_screensaver_title">Bildschirmschoner</string>
|
||||
<string name="setup_screensaver_desc">Zeigt nach 5 Minuten Inaktivität eine Uhr mit nützlichen Fakten. Standardmäßig deaktiviert (Bildschirm bleibt immer an).</string>
|
||||
<string name="setup_screensaver_toggle_label">Bildschirmschoner aktivieren</string>
|
||||
<string name="setup_screensaver_toggle_hint">Wenn deaktiviert, bleibt der Bildschirm immer an.</string>
|
||||
|
||||
<!-- Zusammenfassung -->
|
||||
<string name="summary_lang">Sprache</string>
|
||||
<string name="summary_scale_skip">Waage: nicht konfiguriert</string>
|
||||
<string name="summary_screensaver_on">Bildschirmschoner: aktiv</string>
|
||||
<string name="summary_screensaver_off">Bildschirm immer an (Bildschirmschoner deaktiviert)</string>
|
||||
<string name="summary_prices_on">Einkaufslisten-Preise: aktiviert</string>
|
||||
<string name="summary_mealplan_on">Mahlzeitenplan: aktiviert</string>
|
||||
<string name="summary_zerowaste_on">Zero-Waste-Tipps: aktiviert</string>
|
||||
<string name="summary_gemini_set">Gemini AI: aktiviert</string>
|
||||
<string name="summary_gemini_skip">Gemini AI: nicht konfiguriert</string>
|
||||
<string name="summary_bring_set">Bring!: verbunden</string>
|
||||
<string name="summary_bring_skip">Bring!: nicht konfiguriert</string>
|
||||
<string name="ble_connecting_to">🔗 Verbinde mit %s…</string>
|
||||
<string name="ble_connecting">🔗 Verbindung wird hergestellt…</string>
|
||||
<string name="summary_scale_ok">Waage: %s</string>
|
||||
<string name="summary_scale_warn">Waage: nicht bestätigt</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
<string name="setup_enter_url">Introduce primero una URL</string>
|
||||
<string name="setup_testing">Probando conexión…</string>
|
||||
<string name="setup_server_found">¡Servidor EverShelf encontrado y API activa!</string>
|
||||
<string name="setup_api_not_found">Servidor accesible pero API EverShelf no encontrada. Comprueba la ruta.</string>
|
||||
<string name="setup_unreachable">No se puede alcanzar el servidor</string>
|
||||
<string name="setup_discover_btn">🔍 Buscar en la red local</string>
|
||||
<string name="setup_perms_granted_next">✅ Permisos concedidos — Continuar →</string>
|
||||
<string name="setup_discovering">Escaneando…</string>
|
||||
<string name="setup_discovering_detail">Buscando servidores EverShelf en la red local…</string>
|
||||
<string name="setup_discover_not_found">Ningún servidor EverShelf encontrado automáticamente. Introduce la URL manualmente.</string>
|
||||
<string name="setup_exit_title">¿Salir de la configuración?</string>
|
||||
<string name="setup_exit_message">Puedes completar la configuración más tarde cuando vuelvas a abrir la app.</string>
|
||||
<string name="setup_exit_confirm">Salir</string>
|
||||
<string name="setup_exit_cancel">Continuar</string>
|
||||
<string name="setup_step_back">← Atrás</string>
|
||||
<string name="setup_step_next">Siguiente →</string>
|
||||
<string name="setup_skip_later">Configurar después</string>
|
||||
<string name="setup_confirm">Confirmar →</string>
|
||||
<string name="wizard_step3_title">Báscula inteligente</string>
|
||||
<string name="wizard_step3_description">EverShelf Kiosk incluye una pasarela Bluetooth integrada — no necesitas ninguna app externa. Selecciona tu báscula abajo.</string>
|
||||
<string name="wizard_step3_question">¿Tienes una báscula inteligente Bluetooth?</string>
|
||||
<string name="wizard_step3_yes">✅ Sí, tengo una báscula</string>
|
||||
<string name="wizard_step3_no">➡️ No, saltar este paso</string>
|
||||
<string name="ble_scanning">🔍 Escaneando…</string>
|
||||
<string name="ble_connected">¡Conectado! Coloca un objeto en la báscula…</string>
|
||||
<string name="ble_disconnected">Conexión perdida. Reintentar.</string>
|
||||
<string name="ble_no_scale_found">No se encontró ninguna báscula. Asegúrate de que esté encendida y cerca, e inténtalo de nuevo.</string>
|
||||
<string name="ble_select_from_list">Selecciona tu báscula de la lista.</string>
|
||||
<string name="ble_not_confirmed">Báscula no confirmada. Vuelve a escanear.</string>
|
||||
<string name="ble_scan_again">🔄 Volver a escanear</string>
|
||||
<string name="ble_weight_received">Peso recibido — ¿coincide con el mostrado en la báscula?</string>
|
||||
<string name="wizard_gateway_installed">Báscula guardada ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">La pasarela BLE integrada se conectará automáticamente al inicio.</string>
|
||||
<string name="wizard_gateway_not_installed">Ninguna báscula seleccionada</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Escanea las básculas BLE cercanas y toca una para seleccionarla.</string>
|
||||
<string name="wizard_gateway_checking">Escaneando básculas BLE…</string>
|
||||
<string name="wizard_gateway_up_to_date">Servicio BLE de báscula listo.</string>
|
||||
<string name="wizard_gateway_update_available">Báscula BLE encontrada</string>
|
||||
<string name="wizard_gateway_update_detail">Toca la báscula en la lista para conectarte.</string>
|
||||
<string name="install_downloading">Descargando…</string>
|
||||
<string name="install_downloading_detail">Por favor, espera mientras se descarga el archivo.</string>
|
||||
<string name="install_installing">Instalando…</string>
|
||||
<string name="install_confirm_detail">Confirma la instalación en el diálogo que se ha abierto.</string>
|
||||
<string name="install_success">¡Instalado correctamente!</string>
|
||||
<string name="install_success_detail">La app ha sido actualizada.</string>
|
||||
<string name="install_error_download">Descarga fallida</string>
|
||||
<string name="install_error_download_detail">Comprueba la conexión e inténtalo de nuevo.</string>
|
||||
<string name="install_error_install">Instalación fallida</string>
|
||||
<string name="install_perm_detail">Habilita \'Instalar apps desconocidas\' en los ajustes y vuelve aquí.</string>
|
||||
<string name="install_btn_retry">↩ Reintentar</string>
|
||||
<string name="btn_back">Atrás</string>
|
||||
<string name="btn_launch">🚀 Iniciar EverShelf</string>
|
||||
<string name="btn_launch_no_scale">🚀 Iniciar sin báscula</string>
|
||||
<string name="btn_download_gateway">📥 Instalar Scale Gateway</string>
|
||||
<string name="btn_update_gateway">📥 Actualizar Scale Gateway</string>
|
||||
<string name="wizard_server_checking">Comprobando conexión al servidor…</string>
|
||||
<string name="wizard_server_ok">Servidor accesible ✅</string>
|
||||
<string name="wizard_server_ok_detail">Informe de errores activo — los fallos de instalación se enviarán automáticamente a GitHub Issues.</string>
|
||||
<string name="wizard_server_error">Servidor no accesible ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Los errores no llegarán a GitHub Issues. Comprueba la URL introducida en el paso 2.</string>
|
||||
<string name="setup_features_title">Funcionalidades</string>
|
||||
<string name="setup_features_desc">Activa las funciones que quieras usar. Puedes cambiarlas en cualquier momento desde los ajustes del servidor.</string>
|
||||
<string name="setup_screensaver_toggle_label">Salvapantallas reloj</string>
|
||||
<string name="setup_screensaver_toggle_hint">Muestra un reloj después de 5 min de inactividad.</string>
|
||||
<string name="setup_prices_toggle_label">Precios lista de la compra</string>
|
||||
<string name="setup_prices_toggle_hint">Estimación automática del coste de cada artículo mediante IA.</string>
|
||||
<string name="setup_mealplan_toggle_label">Plan de comidas</string>
|
||||
<string name="setup_mealplan_toggle_hint">Planifica las comidas de la semana con recetas basadas en tu despensa.</string>
|
||||
<string name="setup_zerowaste_toggle_label">Consejos zero-waste</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Muestra consejos para reutilizar restos (cáscaras, agua de cocción, etc.) al cocinar.</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI para sugerencias de recetas, estimaciones inteligentes de la compra y más.\n\nPara activarla, introduce tu clave API de Gemini gratuita.</string>
|
||||
<string name="setup_gemini_how">Obtén tu clave gratuita en: aistudio.google.com → \"Obtener clave API\"</string>
|
||||
<string name="setup_gemini_hint">Pega la clave API aquí (empieza por AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Lista de la compra</string>
|
||||
<string name="setup_bring_desc">EverShelf puede sincronizar tu lista de la compra con la app Bring!.\n\nIntroduce tus credenciales de Bring! para activar la integración.</string>
|
||||
<string name="setup_bring_email_hint">Correo electrónico de Bring!</string>
|
||||
<string name="setup_bring_pass_hint">Contraseña de Bring!</string>
|
||||
<string name="setup_done_title">¡Todo listo!</string>
|
||||
<string name="setup_done_desc">La configuración está completa. Pulsa el botón para iniciar EverShelf en modo quiosco.</string>
|
||||
<string name="setup_done_summary_label">RESUMEN DE CONFIGURACIÓN</string>
|
||||
<string name="summary_lang">Idioma</string>
|
||||
<string name="summary_scale_skip">Báscula: no configurada</string>
|
||||
<string name="summary_screensaver_on">Salvapantallas: activo</string>
|
||||
<string name="summary_screensaver_off">Pantalla siempre encendida (salvapantallas desactivado)</string>
|
||||
<string name="summary_prices_on">Precios lista de la compra: activados</string>
|
||||
<string name="summary_mealplan_on">Plan de comidas: activado</string>
|
||||
<string name="summary_zerowaste_on">Consejos zero-waste: activados</string>
|
||||
<string name="summary_gemini_set">Gemini AI: activada</string>
|
||||
<string name="summary_gemini_skip">Gemini AI: no configurada</string>
|
||||
<string name="summary_bring_set">Bring!: conectada</string>
|
||||
<string name="summary_bring_skip">Bring!: no configurada</string>
|
||||
<string name="ble_connecting_to">🔗 Conectando con %s…</string>
|
||||
<string name="ble_connecting">🔗 Estableciendo conexión…</string>
|
||||
<string name="summary_scale_ok">Báscula: %s</string>
|
||||
<string name="summary_scale_warn">Báscula: no confirmada</string>
|
||||
</resources>
|
||||
@@ -1,100 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
<string name="setup_enter_url">Veuillez d\'abord saisir une URL</string>
|
||||
<string name="setup_testing">Test de connexion…</string>
|
||||
<string name="setup_server_found">Serveur EverShelf trouvé et API active !</string>
|
||||
<string name="setup_api_not_found">Serveur accessible mais API EverShelf introuvable. Vérifiez le chemin.</string>
|
||||
<string name="setup_unreachable">Impossible d\'atteindre le serveur</string>
|
||||
<string name="setup_discover_btn">🔍 Rechercher sur le réseau local</string>
|
||||
<string name="setup_perms_granted_next">✅ Permissions accordées — Continuer →</string>
|
||||
<string name="setup_discovering">Analyse en cours…</string>
|
||||
<string name="setup_discovering_detail">Recherche de serveurs EverShelf sur le réseau local…</string>
|
||||
<string name="setup_discover_not_found">Aucun serveur EverShelf trouvé automatiquement. Entrez l\'URL manuellement.</string>
|
||||
<string name="setup_exit_title">Quitter la configuration ?</string>
|
||||
<string name="setup_exit_message">Vous pouvez terminer la configuration plus tard en rouvrant l\'app.</string>
|
||||
<string name="setup_exit_confirm">Quitter</string>
|
||||
<string name="setup_exit_cancel">Continuer</string>
|
||||
<string name="setup_step_back">← Retour</string>
|
||||
<string name="setup_step_next">Suivant →</string>
|
||||
<string name="setup_skip_later">Configurer plus tard</string>
|
||||
<string name="setup_confirm">Confirmer →</string>
|
||||
<string name="wizard_step3_title">Balance intelligente</string>
|
||||
<string name="wizard_step3_description">EverShelf Kiosk inclut une passerelle Bluetooth intégrée — aucune app externe nécessaire. Sélectionnez votre balance ci-dessous.</string>
|
||||
<string name="wizard_step3_question">Avez-vous une balance intelligente Bluetooth ?</string>
|
||||
<string name="wizard_step3_yes">✅ Oui, j\'ai une balance</string>
|
||||
<string name="wizard_step3_no">➡️ Non, ignorer cette étape</string>
|
||||
<string name="ble_scanning">🔍 Scan en cours…</string>
|
||||
<string name="ble_connected">Connecté ! Posez un objet sur la balance…</string>
|
||||
<string name="ble_disconnected">Connexion perdue. Réessayer.</string>
|
||||
<string name="ble_no_scale_found">Aucune balance trouvée. Vérifiez qu\'elle est allumée et à proximité, puis réessayez.</string>
|
||||
<string name="ble_select_from_list">Sélectionnez votre balance dans la liste.</string>
|
||||
<string name="ble_not_confirmed">Balance non confirmée. Relancer le scan.</string>
|
||||
<string name="ble_scan_again">🔄 Scanner à nouveau</string>
|
||||
<string name="ble_weight_received">Poids reçu — correspond-il à l\'affichage de la balance ?</string>
|
||||
<string name="wizard_gateway_installed">Balance enregistrée ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">La passerelle BLE intégrée se connectera automatiquement au démarrage.</string>
|
||||
<string name="wizard_gateway_not_installed">Aucune balance sélectionnée</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Scannez les balances BLE à proximité et appuyez sur l\'une d\'elles pour la sélectionner.</string>
|
||||
<string name="wizard_gateway_checking">Scan des balances BLE en cours…</string>
|
||||
<string name="wizard_gateway_up_to_date">Service BLE de la balance prêt.</string>
|
||||
<string name="wizard_gateway_update_available">Balance BLE trouvée</string>
|
||||
<string name="wizard_gateway_update_detail">Appuyez sur la balance dans la liste pour vous connecter.</string>
|
||||
<string name="install_downloading">Téléchargement en cours…</string>
|
||||
<string name="install_downloading_detail">Veuillez patienter, le fichier est en cours de téléchargement.</string>
|
||||
<string name="install_installing">Installation en cours…</string>
|
||||
<string name="install_confirm_detail">Confirmez l\'installation dans la boîte de dialogue ouverte.</string>
|
||||
<string name="install_success">Installé avec succès !</string>
|
||||
<string name="install_success_detail">L\'app a été mise à jour.</string>
|
||||
<string name="install_error_download">Téléchargement échoué</string>
|
||||
<string name="install_error_download_detail">Vérifiez la connexion et réessayez.</string>
|
||||
<string name="install_error_install">Installation échouée</string>
|
||||
<string name="install_perm_detail">Activez \'Installer des apps inconnues\' dans les paramètres, puis revenez ici.</string>
|
||||
<string name="install_btn_retry">↩ Réessayer</string>
|
||||
<string name="btn_back">Retour</string>
|
||||
<string name="btn_launch">🚀 Lancer EverShelf</string>
|
||||
<string name="btn_launch_no_scale">🚀 Lancer sans balance</string>
|
||||
<string name="btn_download_gateway">📥 Installer Scale Gateway</string>
|
||||
<string name="btn_update_gateway">📥 Mettre à jour Scale Gateway</string>
|
||||
<string name="wizard_server_checking">Vérification de la connexion au serveur…</string>
|
||||
<string name="wizard_server_ok">Serveur accessible ✅</string>
|
||||
<string name="wizard_server_ok_detail">Rapport d\'erreurs actif — les échecs d\'installation seront envoyés automatiquement aux GitHub Issues.</string>
|
||||
<string name="wizard_server_error">Serveur inaccessible ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Les erreurs n\'atteindront pas GitHub Issues. Vérifiez l\'URL saisie à l\'étape 2.</string>
|
||||
<string name="setup_features_title">Fonctionnalités</string>
|
||||
<string name="setup_features_desc">Activez les fonctions que vous souhaitez utiliser. Vous pourrez les modifier plus tard dans les paramètres du serveur.</string>
|
||||
<string name="setup_screensaver_toggle_label">Horloge écran de veille</string>
|
||||
<string name="setup_screensaver_toggle_hint">Affiche une horloge après 5 min d\'inactivité.</string>
|
||||
<string name="setup_prices_toggle_label">Prix liste de courses</string>
|
||||
<string name="setup_prices_toggle_hint">Estimation automatique du coût de chaque article via IA.</string>
|
||||
<string name="setup_mealplan_toggle_label">Plan de repas</string>
|
||||
<string name="setup_mealplan_toggle_hint">Planifiez les repas de la semaine avec des recettes basées sur votre garde-manger.</string>
|
||||
<string name="setup_zerowaste_toggle_label">Conseils zéro déchet</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Affiche des conseils pour réutiliser les restes (peaux, eau de cuisson, etc.) pendant la cuisson.</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf utilise Google Gemini AI pour les suggestions de recettes, les estimations intelligentes des courses et plus encore.\n\nPour l\'activer, entrez votre clé API Gemini gratuite.</string>
|
||||
<string name="setup_gemini_how">Obtenez votre clé gratuite sur : aistudio.google.com → \"Obtenir une clé API\"</string>
|
||||
<string name="setup_gemini_hint">Collez la clé API ici (commence par AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Liste de courses</string>
|
||||
<string name="setup_bring_desc">EverShelf peut synchroniser votre liste de courses avec l\'app Bring!.\n\nEntrez vos identifiants Bring! pour activer l\'intégration.</string>
|
||||
<string name="setup_bring_email_hint">Adresse e-mail Bring!</string>
|
||||
<string name="setup_bring_pass_hint">Mot de passe Bring!</string>
|
||||
<string name="setup_done_title">Tout est prêt !</string>
|
||||
<string name="setup_done_desc">La configuration est terminée. Appuyez sur le bouton pour lancer EverShelf en mode kiosque.</string>
|
||||
<string name="setup_done_summary_label">RÉSUMÉ DE CONFIGURATION</string>
|
||||
<string name="summary_lang">Langue</string>
|
||||
<string name="summary_scale_skip">Balance : non configurée</string>
|
||||
<string name="summary_screensaver_on">Écran de veille : actif</string>
|
||||
<string name="summary_screensaver_off">Écran toujours allumé (écran de veille désactivé)</string>
|
||||
<string name="summary_prices_on">Prix liste de courses : activés</string>
|
||||
<string name="summary_mealplan_on">Plan de repas : activé</string>
|
||||
<string name="summary_zerowaste_on">Conseils zéro déchet : activés</string>
|
||||
<string name="summary_gemini_set">Gemini AI : activée</string>
|
||||
<string name="summary_gemini_skip">Gemini AI : non configurée</string>
|
||||
<string name="summary_bring_set">Bring! : connectée</string>
|
||||
<string name="summary_bring_skip">Bring! : non configurée</string>
|
||||
<string name="ble_connecting_to">🔗 Connexion à %s…</string>
|
||||
<string name="ble_connecting">🔗 Connexion en cours…</string>
|
||||
<string name="summary_scale_ok">Balance : %s</string>
|
||||
<string name="summary_scale_warn">Balance : non confirmée</string>
|
||||
</resources>
|
||||
@@ -1,45 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
|
||||
<!-- Stringhe setup wizard -->
|
||||
<string name="setup_enter_url">Inserisci prima un URL</string>
|
||||
<string name="setup_testing">Verifica connessione…</string>
|
||||
<string name="setup_server_found">Server EverShelf trovato e API attiva!</string>
|
||||
<string name="setup_api_not_found">Server raggiungibile ma API EverShelf non trovata. Verifica il percorso.</string>
|
||||
<string name="setup_unreachable">Impossibile raggiungere il server</string>
|
||||
<string name="setup_discover_btn">🔍 Cerca nella rete locale</string>
|
||||
<string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string>
|
||||
<string name="setup_discovering">Scansione in corso…</string>
|
||||
<string name="setup_discover_btn">🔍 Cerca nella rete locale</string> <string name="setup_perms_granted_next">✅ Permessi concessi — Continua →</string> <string name="setup_discovering">Scansione in corso…</string>
|
||||
<string name="setup_discovering_detail">Ricerca server EverShelf nella rete locale…</string>
|
||||
<string name="setup_discover_not_found">Nessun server EverShelf trovato automaticamente. Inserisci l\'URL manualmente.</string>
|
||||
<string name="setup_exit_title">Uscire dalla configurazione?</string>
|
||||
<string name="setup_exit_message">Puoi completare la configurazione più tardi riaprendo l\'app.</string>
|
||||
<string name="setup_exit_confirm">Esci</string>
|
||||
<string name="setup_exit_cancel">Continua</string>
|
||||
<string name="setup_step_back">← Indietro</string>
|
||||
<string name="setup_step_next">Avanti →</string>
|
||||
<string name="setup_skip_later">Lo faccio dopo</string>
|
||||
<string name="setup_confirm">Conferma →</string>
|
||||
|
||||
<!-- Wizard Step 3: Bilancia smart -->
|
||||
<string name="wizard_step3_title">Bilancia Smart</string>
|
||||
<string name="wizard_step3_description">EverShelf Kiosk include un gateway Bluetooth integrato — nessuna app esterna necessaria. Seleziona la tua bilancia qui sotto.</string>
|
||||
<string name="wizard_step3_question">Hai una bilancia smart Bluetooth?</string>
|
||||
<string name="wizard_step3_yes">✅ Sì, ho una bilancia</string>
|
||||
<string name="wizard_step3_no">➡️ No, salta questo passaggio</string>
|
||||
<string name="ble_scanning">🔍 Scansione in corso…</string>
|
||||
<string name="ble_connected">Connesso! Posiziona un oggetto sulla bilancia…</string>
|
||||
<string name="ble_disconnected">Connessione persa. Riprova.</string>
|
||||
<string name="ble_no_scale_found">Nessuna bilancia trovata. Assicurati che sia accesa e vicina, poi riprova.</string>
|
||||
<string name="ble_select_from_list">Seleziona la tua bilancia dall\'elenco.</string>
|
||||
<string name="ble_not_confirmed">Bilancia non confermata. Riprova la scansione.</string>
|
||||
<string name="ble_scan_again">🔄 Scansiona di nuovo</string>
|
||||
<string name="ble_weight_received">Peso ricevuto — coincide con quello sulla bilancia?</string>
|
||||
|
||||
<!-- Messaggi stato gateway -->
|
||||
<string name="wizard_gateway_installed">Bilancia salvata ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all\'avvio.</string>
|
||||
<string name="wizard_gateway_installed_detail">Il gateway BLE integrato si collegherà automaticamente all\'avvio.</string>
|
||||
<string name="wizard_gateway_not_installed">Nessuna bilancia selezionata</string>
|
||||
<string name="wizard_gateway_not_installed_detail">Scansiona le bilance BLE nelle vicinanze e tocca una per selezionarla.</string>
|
||||
<string name="wizard_gateway_checking">Scansione bilance BLE in corso…</string>
|
||||
<string name="wizard_gateway_up_to_date">Servizio BLE bilancia pronto.</string>
|
||||
<string name="wizard_gateway_update_available">Bilancia BLE trovata</string>
|
||||
<string name="wizard_gateway_update_detail">Tocca la bilancia nell\'elenco per connettersi.</string>
|
||||
|
||||
<!-- Stati scaricamento / installazione -->
|
||||
<string name="install_downloading">Scaricamento in corso…</string>
|
||||
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
|
||||
<string name="install_installing">Installazione in corso…</string>
|
||||
@@ -51,50 +45,29 @@
|
||||
<string name="install_error_install">Installazione fallita</string>
|
||||
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
|
||||
<string name="install_btn_retry">↩ Riprova</string>
|
||||
|
||||
<!-- Pulsanti -->
|
||||
<string name="btn_back">Indietro</string>
|
||||
<string name="btn_launch">🚀 Avvia EverShelf</string>
|
||||
<string name="btn_launch_no_scale">🚀 Avvia senza bilancia</string>
|
||||
<string name="btn_download_gateway">📥 Installa Scale Gateway</string>
|
||||
<string name="btn_update_gateway">📥 Aggiorna Scale Gateway</string>
|
||||
|
||||
<!-- Verifica raggiungibilità server (step 3 wizard) -->
|
||||
<string name="wizard_server_checking">Verifica connessione server…</string>
|
||||
<string name="wizard_server_ok">Server raggiungibile ✅</string>
|
||||
<string name="wizard_server_ok_detail">Segnalazione errori attiva — i problemi di installazione vengono inviati automaticamente alle GitHub Issues.</string>
|
||||
<string name="wizard_server_error">Server non raggiungibile ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Gli errori non raggiungeranno GitHub Issues. Verifica l\'URL inserito al passaggio 2.</string>
|
||||
<string name="setup_features_title">Funzionalità</string>
|
||||
<string name="setup_features_desc">Attiva le funzioni che vuoi usare. Puoi sempre cambiarle in seguito dalle impostazioni del server.</string>
|
||||
<string name="setup_screensaver_toggle_label">Salvaschermo orologio</string>
|
||||
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min di inattività.</string>
|
||||
<string name="setup_prices_toggle_label">Prezzi lista spesa</string>
|
||||
<string name="setup_prices_toggle_hint">Stima automatica del costo di ogni articolo in lista tramite AI.</string>
|
||||
<string name="setup_mealplan_toggle_label">Piano pasti</string>
|
||||
<string name="setup_mealplan_toggle_hint">Pianifica i pasti della settimana suggerendo ricette basate sulla dispensa.</string>
|
||||
<string name="setup_zerowaste_toggle_label">Suggerimenti zero-waste</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Durante la cottura mostra consigli per riutilizzare scarti (bucce, acqua di cottura, ecc.).</string>
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf usa Google Gemini AI per suggerimenti di ricette, stime intelligenti della spesa e altro ancora.\n\nPer abilitarla, inserisci la tua chiave API Gemini gratuita.</string>
|
||||
<string name="setup_gemini_how">Ottieni la chiave gratuita su: aistudio.google.com → \"Ottieni chiave API\"</string>
|
||||
<string name="setup_gemini_hint">Incolla la chiave API (inizia con AIza…)</string>
|
||||
<string name="setup_bring_title">Bring! Lista della spesa</string>
|
||||
<string name="setup_bring_desc">EverShelf può sincronizzare la lista della spesa con l\'app Bring!.\n\nInserisci le credenziali del tuo account Bring! per abilitare l\'integrazione.</string>
|
||||
<string name="setup_bring_email_hint">Email Bring!</string>
|
||||
<string name="setup_bring_pass_hint">Password Bring!</string>
|
||||
<string name="setup_done_title">Tutto pronto!</string>
|
||||
<string name="setup_done_desc">La configurazione è completa. Premi il pulsante per avviare EverShelf in modalità kiosk.</string>
|
||||
<string name="setup_done_summary_label">RIEPILOGO CONFIGURAZIONE</string>
|
||||
<!-- Passo salvaschermo -->
|
||||
<string name="setup_screensaver_title">Salvaschermo</string>
|
||||
<string name="setup_screensaver_desc">Mostra un orologio con fatti utili dopo 5 minuti di inattività. Di default è disattivato (lo schermo resta sempre acceso).</string>
|
||||
<string name="setup_screensaver_toggle_label">Attiva salvaschermo</string>
|
||||
<string name="setup_screensaver_toggle_hint">Se disattivo, lo schermo resta sempre acceso.</string>
|
||||
|
||||
<!-- Riepilogo -->
|
||||
<string name="summary_lang">Lingua</string>
|
||||
<string name="summary_scale_skip">Bilancia: non configurata</string>
|
||||
<string name="summary_screensaver_on">Salvaschermo: attivo</string>
|
||||
<string name="summary_screensaver_off">Schermo sempre acceso (salvaschermo disattivato)</string>
|
||||
<string name="summary_prices_on">Prezzi lista spesa: abilitati</string>
|
||||
<string name="summary_mealplan_on">Piano pasti: abilitato</string>
|
||||
<string name="summary_zerowaste_on">Suggerimenti zero-waste: abilitati</string>
|
||||
<string name="summary_gemini_set">Gemini AI: abilitata</string>
|
||||
<string name="summary_gemini_skip">Gemini AI: non configurata</string>
|
||||
<string name="summary_bring_set">Bring!: connessa</string>
|
||||
<string name="summary_bring_skip">Bring!: non configurata</string>
|
||||
<string name="ble_connecting_to">🔗 Connessione a %s…</string>
|
||||
<string name="ble_connecting">🔗 Connessione in corso…</string>
|
||||
<string name="summary_scale_ok">Bilancia: %s</string>
|
||||
<string name="summary_scale_warn">Bilancia: da configurare</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -1,45 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Kiosk</string>
|
||||
|
||||
<!-- ── Setup wizard ─────────────────────────────────────────────────── -->
|
||||
<!-- Setup wizard strings -->
|
||||
<string name="setup_enter_url">Please enter a URL first</string>
|
||||
<string name="setup_testing">Testing connection…</string>
|
||||
<string name="setup_server_found">EverShelf server found and API active!</string>
|
||||
<string name="setup_api_not_found">Server reachable but EverShelf API not found. Check the path.</string>
|
||||
<string name="setup_unreachable">Cannot reach server</string>
|
||||
<string name="setup_discover_btn">🔍 Search local network</string>
|
||||
<string name="setup_perms_granted_next">✅ Permissions granted — Continue →</string>
|
||||
<string name="setup_discovering">Scanning…</string>
|
||||
<string name="setup_discover_btn">🔍 Search local network</string> <string name="setup_perms_granted_next">✅ Permissions granted — Continue →</string> <string name="setup_discovering">Scanning…</string>
|
||||
<string name="setup_discovering_detail">Searching for EverShelf servers on the local network…</string>
|
||||
<string name="setup_discover_not_found">No EverShelf server found automatically. Enter the URL manually.</string>
|
||||
<string name="setup_exit_title">Exit setup?</string>
|
||||
<string name="setup_exit_message">You can complete setup later when you reopen the app.</string>
|
||||
<string name="setup_exit_confirm">Exit</string>
|
||||
<string name="setup_exit_cancel">Continue</string>
|
||||
<string name="setup_step_back">← Back</string>
|
||||
<string name="setup_step_next">Next →</string>
|
||||
<string name="setup_skip_later">Set up later</string>
|
||||
<string name="setup_confirm">Confirm →</string>
|
||||
|
||||
<!-- ── Wizard Step 4: Smart scale ───────────────────────────────────── -->
|
||||
<!-- Wizard Step 3: Smart scale -->
|
||||
<string name="wizard_step3_title">Smart Scale</string>
|
||||
<string name="wizard_step3_description">EverShelf Kiosk includes a built-in Bluetooth gateway — no external app needed. Select your scale below.</string>
|
||||
<string name="wizard_step3_question">Do you have a Bluetooth smart scale?</string>
|
||||
<string name="wizard_step3_yes">✅ Yes, I have a scale</string>
|
||||
<string name="wizard_step3_no">➡️ No, skip this step</string>
|
||||
|
||||
<!-- BLE scan / test feedback (previously hardcoded) -->
|
||||
<string name="ble_scanning">🔍 Scanning…</string>
|
||||
<string name="ble_connected">Connected! Place an object on the scale…</string>
|
||||
<string name="ble_disconnected">Connection lost. Retry.</string>
|
||||
<string name="ble_no_scale_found">No scale found. Make sure it is on and nearby, then retry.</string>
|
||||
<string name="ble_select_from_list">Select your scale from the list.</string>
|
||||
<string name="ble_not_confirmed">Scale not confirmed. Retry scan.</string>
|
||||
<string name="ble_scan_again">🔄 Scan again</string>
|
||||
<string name="ble_weight_received">Weight received — does it match the display?</string>
|
||||
|
||||
<!-- ── Gateway status messages ──────────────────────────────────────── -->
|
||||
<!-- Gateway status messages -->
|
||||
<string name="wizard_gateway_installed">Scale device saved ✅</string>
|
||||
<string name="wizard_gateway_installed_detail">The integrated BLE gateway will connect automatically on startup.</string>
|
||||
<string name="wizard_gateway_not_installed">No scale selected</string>
|
||||
@@ -49,76 +32,41 @@
|
||||
<string name="wizard_gateway_update_available">BLE scale found</string>
|
||||
<string name="wizard_gateway_update_detail">Tap the scale in the list to connect.</string>
|
||||
|
||||
<!-- ── Install / download progress states ───────────────────────────── -->
|
||||
<string name="install_downloading">Downloading…</string>
|
||||
<string name="install_downloading_detail">Please wait, the file is being downloaded.</string>
|
||||
<string name="install_installing">Installing…</string>
|
||||
<string name="install_confirm_detail">Confirm the installation in the dialog that has opened.</string>
|
||||
<string name="install_success">Installed successfully!</string>
|
||||
<string name="install_success_detail">The app has been updated.</string>
|
||||
<string name="install_error_download">Download failed</string>
|
||||
<string name="install_error_download_detail">Check your connection and try again.</string>
|
||||
<string name="install_error_install">Installation failed</string>
|
||||
<string name="install_perm_detail">Enable \'Install unknown apps\' in settings, then come back here.</string>
|
||||
<string name="install_btn_retry">↩ Retry</string>
|
||||
<!-- Install / download progress states -->
|
||||
<string name="install_downloading">Scaricamento in corso…</string>
|
||||
<string name="install_downloading_detail">Attendi, il file viene scaricato.</string>
|
||||
<string name="install_installing">Installazione in corso…</string>
|
||||
<string name="install_confirm_detail">Conferma l\'installazione nel dialog che si è aperto.</string>
|
||||
<string name="install_success">Installato con successo!</string>
|
||||
<string name="install_success_detail">L\'app è stata aggiornata.</string>
|
||||
<string name="install_error_download">Download fallito</string>
|
||||
<string name="install_error_download_detail">Controlla la connessione e riprova.</string>
|
||||
<string name="install_error_install">Installazione fallita</string>
|
||||
<string name="install_perm_detail">Abilita \'Installa app sconosciute\' nelle impostazioni, poi torna qui.</string>
|
||||
<string name="install_btn_retry">↩ Riprova</string>
|
||||
|
||||
<!-- ── Buttons ───────────────────────────────────────────────────────── -->
|
||||
<!-- Buttons -->
|
||||
<string name="btn_back">Back</string>
|
||||
<string name="btn_launch">🚀 Launch EverShelf</string>
|
||||
<string name="btn_launch_no_scale">🚀 Launch without scale</string>
|
||||
<string name="btn_download_gateway">📥 Install Scale Gateway</string>
|
||||
<string name="btn_update_gateway">📥 Update Scale Gateway</string>
|
||||
|
||||
<!-- ── Server reachability check ────────────────────────────────────── -->
|
||||
<!-- Server reachability check (wizard step 3) -->
|
||||
<string name="wizard_server_checking">Checking server connection…</string>
|
||||
<string name="wizard_server_ok">Server reachable ✅</string>
|
||||
<string name="wizard_server_ok_detail">Error reporting is active — install failures will be sent to GitHub Issues automatically.</string>
|
||||
<string name="wizard_server_error">Server not reachable ⚠️</string>
|
||||
<string name="wizard_server_error_detail">Install errors won\'t reach GitHub Issues. Check the URL entered in step 2.</string>
|
||||
<!-- Screensaver step -->
|
||||
<string name="setup_screensaver_title">Salvaschermo in-app</string>
|
||||
<string name="setup_screensaver_desc">Shows a clock with useful facts after 5 minutes of inactivity. Off by default (screen stays always on).</string>
|
||||
<string name="setup_screensaver_toggle_label">Abilita salvaschermo orologio</string>
|
||||
<string name="setup_screensaver_toggle_hint">Mostra l\'overlay orologio dopo 5 min. Lo schermo resta sempre acceso.</string>
|
||||
|
||||
<!-- ── Step 5 — Features ─────────────────────────────────────────────── -->
|
||||
<string name="setup_features_title">Features</string>
|
||||
<string name="setup_features_desc">Enable the features you want to use. You can always change them later in the server settings.</string>
|
||||
<string name="setup_screensaver_toggle_label">Clock screensaver</string>
|
||||
<string name="setup_screensaver_toggle_hint">Shows a clock overlay after 5 min of inactivity.</string>
|
||||
<string name="setup_prices_toggle_label">Shopping list prices</string>
|
||||
<string name="setup_prices_toggle_hint">AI-powered automatic cost estimate for each item in the list.</string>
|
||||
<string name="setup_mealplan_toggle_label">Meal plan</string>
|
||||
<string name="setup_mealplan_toggle_hint">Plan the week\'s meals with recipes based on your pantry.</string>
|
||||
<string name="setup_zerowaste_toggle_label">Zero-waste tips</string>
|
||||
<string name="setup_zerowaste_toggle_hint">Show tips for reusing scraps (peels, cooking water, etc.) while cooking.</string>
|
||||
|
||||
<!-- ── Step 6 — Gemini AI key ─────────────────────────────────────────── -->
|
||||
<string name="setup_gemini_title">Google Gemini AI</string>
|
||||
<string name="setup_gemini_desc">EverShelf uses Google Gemini AI for recipe suggestions, smart shopping estimates and more.\n\nTo enable it, enter your free Gemini API key below.</string>
|
||||
<string name="setup_gemini_how">Get your free key at: aistudio.google.com → \"Get API key\"</string>
|
||||
<string name="setup_gemini_hint">Paste your API key here (starts with AIza…)</string>
|
||||
|
||||
<!-- ── Step 7 — Bring! credentials ──────────────────────────────────── -->
|
||||
<string name="setup_bring_title">Bring! Shopping List</string>
|
||||
<string name="setup_bring_desc">EverShelf can sync your shopping list with the Bring! app.\n\nEnter your Bring! account credentials to enable this integration.</string>
|
||||
<string name="setup_bring_email_hint">Bring! email address</string>
|
||||
<string name="setup_bring_pass_hint">Bring! password</string>
|
||||
|
||||
<!-- ── Step 8 — Done ─────────────────────────────────────────────────── -->
|
||||
<string name="setup_done_title">All set!</string>
|
||||
<string name="setup_done_desc">Setup is complete. Press the button below to launch EverShelf in kiosk mode.</string>
|
||||
<string name="setup_done_summary_label">CONFIGURATION SUMMARY</string>
|
||||
|
||||
<!-- ── Summary lines ─────────────────────────────────────────────────── -->
|
||||
<!-- Summary -->
|
||||
<string name="summary_lang">Language</string>
|
||||
<string name="summary_scale_skip">Scale: not configured</string>
|
||||
<string name="summary_screensaver_on">Screensaver: enabled</string>
|
||||
<string name="summary_screensaver_off">Screen always on (screensaver disabled)</string>
|
||||
<string name="summary_prices_on">Shopping list prices: enabled</string>
|
||||
<string name="summary_mealplan_on">Meal plan: enabled</string>
|
||||
<string name="summary_zerowaste_on">Zero-waste tips: enabled</string>
|
||||
<string name="summary_gemini_set">Gemini AI: enabled</string>
|
||||
<string name="summary_gemini_skip">Gemini AI: not configured</string>
|
||||
<string name="summary_bring_set">Bring!: connected</string>
|
||||
<string name="summary_bring_skip">Bring!: not configured</string>
|
||||
<string name="ble_connecting_to">🔗 Connecting to %s…</string>
|
||||
<string name="ble_connecting">🔗 Connecting…</string>
|
||||
<string name="summary_scale_ok">Scale: %s</string>
|
||||
<string name="summary_scale_warn">Scale: not confirmed</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# Build trigger: versionName 1.7.13 fix (8d87494)
|
||||
# Build trigger: TTS bridge fix (95389eb)
|
||||
# Build trigger: v1.7.14 with openNativeSettings fix (834d8ef)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
*.apk
|
||||
*.aab
|
||||
*.class
|
||||
*.dex
|
||||
@@ -0,0 +1,156 @@
|
||||
# ~~EverShelf Scale Gateway~~ — DEPRECATED
|
||||
|
||||
> ⚠️ **This app is deprecated and no longer maintained.**
|
||||
>
|
||||
> As of **EverShelf Kiosk v1.6.0**, BLE scale support is fully integrated into the kiosk app itself. You no longer need to install or configure this separate gateway app.
|
||||
>
|
||||
> **If you are using the EverShelf Kiosk app** → the scale gateway runs automatically as a background service. Configure your Bluetooth scale in **step 4 of the setup wizard**.
|
||||
>
|
||||
> **If you are NOT using the kiosk app** (standalone Android tablet) → you may still use this APK, but no new releases will be published.
|
||||
|
||||
---
|
||||
|
||||
# EverShelf Scale Gateway (legacy)
|
||||
|
||||
> Android gateway app that bridges Bluetooth LE smart scales with EverShelf via WebSocket.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Smart Scale ──(BLE)──► Android Gateway App ──(WebSocket/LAN)──► EverShelf Server ──(SSE)──► Browser
|
||||
```
|
||||
|
||||
The app runs a local WebSocket server (port **8765**) on your Android device. The EverShelf server connects to it via a server-side relay (`api/scale_relay.php` SSE + `api/scale_ping.php` WebSocket client), avoiding mixed-content (HTTPS→WS) issues. Weight readings are streamed to the browser in real time.
|
||||
|
||||
> **Kiosk integration (v1.6.0+):** The gateway is now **built into the EverShelf Kiosk app** as a foreground service. This separate app is not needed when using the kiosk.
|
||||
|
||||
---
|
||||
|
||||
## Supported scale protocols
|
||||
|
||||
| Protocol | Service UUID | Notes |
|
||||
|---|---|---|
|
||||
| **Bluetooth SIG Weight Scale** | `0x181D` / char `0x2A9D` | Most compatible; works with most smart scales |
|
||||
| **Bluetooth SIG Body Composition** | `0x181B` / char `0x2A9C` | Reports weight + body fat %, BMI |
|
||||
| **Generic fallback** | Any notifiable characteristic | Auto-heuristic parsing for 100+ models |
|
||||
|
||||
### Verified compatible scales (community list)
|
||||
- Xiaomi Mi Body Composition Scale 2
|
||||
- Renpho Smart Body Fat Scale
|
||||
- INEVIFIT Smart Body Fat Scale
|
||||
- Any OpenScale-compatible scale (see [openScale supported devices](https://github.com/oliexdev/openScale/wiki/Supported-scales))
|
||||
|
||||
> **Your scale (B09MRXVBV6):** If it implements the standard BLE Weight Scale or Body Composition profile (very likely for modern Amazon smart scales), the gateway will connect automatically. If not, check the [openScale wiki](https://github.com/oliexdev/openScale/wiki/Supported-scales) and open an issue.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
Download the latest APK directly: **[evershelf-scale-gateway.apk](https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk)**
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android **7.0** (API 24) or later
|
||||
- Bluetooth LE (BLE) support
|
||||
- Both the Android device and the device running EverShelf must be on the **same Wi-Fi network**
|
||||
|
||||
---
|
||||
|
||||
## Setup (step by step)
|
||||
|
||||
### 1. Install the APK
|
||||
Download and install the APK from the Releases page. You may need to allow "Install from unknown sources" in Android settings.
|
||||
|
||||
### 2. Launch the app
|
||||
The app starts the WebSocket gateway server immediately. You will see the **gateway URL** (e.g. `ws://192.168.1.100:8765`) at the top.
|
||||
|
||||
### 3. Connect your scale
|
||||
Tap **"Cerca Bilance Bluetooth"** (Find Bluetooth Scales). Make sure your scale is turned on. Tap it in the list to connect.
|
||||
|
||||
### 4. Configure EverShelf
|
||||
In EverShelf → ⚙️ Settings → **⚖️ Bilancia Smart**:
|
||||
1. Enable the toggle
|
||||
2. Paste the gateway URL shown in the Android app
|
||||
3. Tap **"Testa connessione"** — you should see ✅
|
||||
|
||||
### 5. Use it
|
||||
When adding or consuming a product with unit **g** or **ml**, a **"⚖️ Leggi dalla bilancia"** button appears. Tap it, place the product on the scale, and the weight is filled in automatically.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket protocol reference
|
||||
|
||||
All messages are JSON. The server sends these to connected clients:
|
||||
|
||||
```json
|
||||
// Scale status update
|
||||
{"type":"status","state":"connected","device":"Mi Scale 2","battery":85}
|
||||
{"type":"status","state":"disconnected"}
|
||||
|
||||
// Weight reading (broadcast continuously while scale is active)
|
||||
{"type":"weight","value":72.50,"unit":"kg","stable":true,"timestamp":1712345678000}
|
||||
|
||||
// Response to ping
|
||||
{"type":"pong"}
|
||||
```
|
||||
|
||||
Clients can send:
|
||||
|
||||
```json
|
||||
{"type":"get_status"} // Request current status
|
||||
{"type":"get_weight"} // Request next stable weight reading
|
||||
{"type":"ping"} // Keep-alive
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build from source
|
||||
|
||||
### Prerequisites
|
||||
- Android Studio Hedgehog (2023.1) or later
|
||||
- Java 8+
|
||||
|
||||
### Steps
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
git clone https://github.com/dadaloop82/EverShelf.git
|
||||
cd EverShelf/evershelf-scale-gateway
|
||||
|
||||
# 2. Download the Gradle wrapper (if not included)
|
||||
gradle wrapper --gradle-version 8.4
|
||||
|
||||
# 3. Build debug APK
|
||||
./gradlew assembleDebug
|
||||
|
||||
# APK is at: app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
evershelf-scale-gateway/
|
||||
├── app/src/main/
|
||||
│ ├── kotlin/it/dadaloop/evershelf/scalegate/
|
||||
│ │ ├── MainActivity.kt — UI, orchestration
|
||||
│ │ ├── BleScaleManager.kt — BLE scanning & GATT connection
|
||||
│ │ ├── ScaleProtocol.kt — Parsing for all supported protocols
|
||||
│ │ └── GatewayWebSocketServer.kt — WebSocket server (Java-WebSocket)
|
||||
│ ├── res/layout/
|
||||
│ │ ├── activity_main.xml
|
||||
│ │ └── item_device.xml
|
||||
│ └── AndroidManifest.xml
|
||||
├── build.gradle.kts
|
||||
└── settings.gradle.kts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../LICENSE)
|
||||
@@ -0,0 +1,41 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "it.dadaloop.evershelf.scalegate"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "it.dadaloop.evershelf.scalegate"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 8
|
||||
versionName = "2.1.1"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
// WebSocket server
|
||||
implementation("org.java-websocket:Java-WebSocket:1.5.5")
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- BLE permissions for Android < 12 -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<!-- BLE permissions for Android 12+ -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- Location (required for BLE scanning on Android 6–11) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Network (for WebSocket server) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<!-- Keep screen on while gateway is active -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Self-update: install APK downloaded at runtime -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MaterialComponents.Light.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- FileProvider for serving the downloaded APK to the installer -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,455 @@
|
||||
package it.dadaloop.evershelf.scalegate
|
||||
|
||||
import android.Manifest
|
||||
import android.bluetooth.*
|
||||
import android.bluetooth.le.*
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
private const val TAG = "BleScaleManager"
|
||||
private const val SCAN_PERIOD_MS = 15_000L
|
||||
private const val PREFS_NAME = "evershelf_gateway"
|
||||
private const val PREF_LAST_DEVICE = "last_device_address"
|
||||
|
||||
/**
|
||||
* Represents a discovered BLE device during scan.
|
||||
*/
|
||||
data class BleDeviceInfo(
|
||||
val device: BluetoothDevice,
|
||||
val name: String,
|
||||
val rssi: Int,
|
||||
val proximity: String,
|
||||
val scaleScore: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Callback interface for BLE events dispatched back to the UI.
|
||||
*/
|
||||
interface BleScaleListener {
|
||||
fun onDeviceFound(info: BleDeviceInfo)
|
||||
fun onConnecting(device: BluetoothDevice)
|
||||
fun onConnected(deviceName: String)
|
||||
fun onDisconnected()
|
||||
fun onWeightReceived(reading: WeightReading)
|
||||
fun onBatteryReceived(level: Int)
|
||||
fun onError(message: String)
|
||||
fun onScanStopped()
|
||||
fun onDebugEvent(message: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages BLE scanning and connection to a smart scale.
|
||||
* All listener callbacks are dispatched on the main thread.
|
||||
*/
|
||||
class BleScaleManager(
|
||||
private val context: Context,
|
||||
private val listener: BleScaleListener,
|
||||
) {
|
||||
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
private val bluetoothAdapter: BluetoothAdapter? get() = bluetoothManager.adapter
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private var leScanner: BluetoothLeScanner? = null
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var isScanning = false
|
||||
private var connectedDeviceName: String = ""
|
||||
private var autoConnectAddress: String? = null
|
||||
|
||||
// The characteristics we will subscribe to (multiple may exist).
|
||||
private val pendingSubscriptions = ArrayDeque<BluetoothGattCharacteristic>()
|
||||
|
||||
// ─── Public state ──────────────────────────────────────────────────────────
|
||||
|
||||
val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty()
|
||||
|
||||
// ─── Saved device (auto-reconnect) ─────────────────────────────────────────
|
||||
|
||||
fun getSavedDeviceAddress(): String? {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(PREF_LAST_DEVICE, null)
|
||||
}
|
||||
|
||||
private fun saveDeviceAddress(address: String) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putString(PREF_LAST_DEVICE, address).apply()
|
||||
}
|
||||
|
||||
fun enableAutoConnect() {
|
||||
autoConnectAddress = getSavedDeviceAddress()
|
||||
}
|
||||
|
||||
// ─── Permissions helper ────────────────────────────────────────────────────
|
||||
|
||||
fun hasRequiredPermissions(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scanning ──────────────────────────────────────────────────────────────
|
||||
|
||||
fun startScan() {
|
||||
val adapter = bluetoothAdapter ?: run {
|
||||
listener.onError("Bluetooth not available on this device.")
|
||||
return
|
||||
}
|
||||
if (!adapter.isEnabled) {
|
||||
listener.onError("Bluetooth is off. Enable it and try again.")
|
||||
return
|
||||
}
|
||||
if (isScanning) stopScan()
|
||||
|
||||
leScanner = adapter.bluetoothLeScanner
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
|
||||
// No service UUID filters — many consumer scales use proprietary UUIDs
|
||||
// and would be invisible with strict filtering. We show all named BLE devices.
|
||||
isScanning = true
|
||||
try {
|
||||
leScanner?.startScan(null, settings, scanCallback)
|
||||
} catch (e: Exception) {
|
||||
leScanner?.startScan(scanCallback)
|
||||
}
|
||||
|
||||
// Auto-stop after SCAN_PERIOD_MS
|
||||
mainHandler.postDelayed({
|
||||
stopScan()
|
||||
listener.onScanStopped()
|
||||
}, SCAN_PERIOD_MS)
|
||||
}
|
||||
|
||||
fun stopScan() {
|
||||
if (!isScanning) return
|
||||
isScanning = false
|
||||
try {
|
||||
leScanner?.stopScan(scanCallback)
|
||||
} catch (e: Exception) { /* ignore */ }
|
||||
leScanner = null
|
||||
}
|
||||
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val device = result.device
|
||||
val name = result.scanRecord?.deviceName?.takeIf { it.isNotBlank() }
|
||||
?: getDeviceName(device)
|
||||
val proximity = rssiToProximity(result.rssi)
|
||||
val score = scoreLikelyScale(name, result.scanRecord)
|
||||
val info = BleDeviceInfo(device, name, result.rssi, proximity, score)
|
||||
mainHandler.post { listener.onDeviceFound(info) }
|
||||
|
||||
// Auto-connect to saved device
|
||||
if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) {
|
||||
autoConnectAddress = null // prevent re-trigger
|
||||
mainHandler.post {
|
||||
listener.onDebugEvent("\uD83D\uDD04 Auto-connecting to $name (${device.address})")
|
||||
connect(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
isScanning = false
|
||||
mainHandler.post { listener.onError("BLE scan failed (code: $errorCode)") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeviceName(device: BluetoothDevice): String {
|
||||
return try {
|
||||
device.name?.takeIf { it.isNotBlank() } ?: "Unnamed"
|
||||
} catch (e: SecurityException) {
|
||||
"Unnamed"
|
||||
}
|
||||
}
|
||||
|
||||
private fun rssiToProximity(rssi: Int) = when {
|
||||
rssi >= -60 -> "📶 Near"
|
||||
rssi >= -80 -> "📶 Medium"
|
||||
else -> "📶 Far"
|
||||
}
|
||||
|
||||
private fun scoreLikelyScale(name: String, scanRecord: android.bluetooth.le.ScanRecord?): Int {
|
||||
var score = 0
|
||||
val lower = name.lowercase()
|
||||
// Kitchen / food scale brand and model keywords
|
||||
val foodKeywords = listOf(
|
||||
"scale", "bilancia", "kitchen", "food", "cucina",
|
||||
"coffee", "caffe", "balance", "weight", "waage",
|
||||
"arboleaf", "ck10", "ck20", "ek-",
|
||||
"acaia", "felicita", "decent", "skale",
|
||||
"timemore", "brewista", "hario",
|
||||
"greater goods", "ozeri", "etekcity", "nutri",
|
||||
"nicewell", "koios", "renpho", "eatsmart",
|
||||
)
|
||||
if (foodKeywords.any { lower.contains(it) }) score += 10
|
||||
|
||||
// Negative: body/fitness scale keywords (demote but don't hide)
|
||||
val bodyKeywords = listOf(
|
||||
"body", "fat", "bmi", "composition", "fitness",
|
||||
"mi body", "lepulse", "qardio", "garmin", "withings",
|
||||
)
|
||||
if (bodyKeywords.any { lower.contains(it) }) score -= 5
|
||||
|
||||
// Service UUID scoring
|
||||
scanRecord?.serviceUuids?.let { uuids ->
|
||||
val us = uuids.map { it.uuid.toString().lowercase() }
|
||||
// SIG Weight Scale service
|
||||
if (us.any { it.startsWith("0000181d") }) score += 15
|
||||
// Common vendor services on kitchen scales
|
||||
if (us.any { it.startsWith("0000ffe0") || it.startsWith("0000fff0") }) score += 10
|
||||
// Acaia coffee scale
|
||||
if (us.any { it.startsWith("49535343") }) score += 20
|
||||
// Body Composition service = body scale, demote
|
||||
if (us.any { it.startsWith("0000181b") }) score -= 10
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// ─── Connection ────────────────────────────────────────────────────────────
|
||||
|
||||
fun connect(device: BluetoothDevice) {
|
||||
stopScan()
|
||||
disconnect()
|
||||
connectedDeviceName = ""
|
||||
ScaleProtocol.resetState()
|
||||
mainHandler.post { listener.onConnecting(device) }
|
||||
try {
|
||||
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
} else {
|
||||
device.connectGatt(context, false, gattCallback)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
mainHandler.post { listener.onError("Missing permission: ${e.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
pendingSubscriptions.clear()
|
||||
try {
|
||||
gatt?.disconnect()
|
||||
gatt?.close()
|
||||
} catch (e: Exception) { /* ignore */ }
|
||||
gatt = null
|
||||
connectedDeviceName = ""
|
||||
}
|
||||
|
||||
// ─── GATT callbacks ────────────────────────────────────────────────────────
|
||||
|
||||
private val gattCallback = object : BluetoothGattCallback() {
|
||||
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
Log.d(TAG, "Connected — discovering services…")
|
||||
mainHandler.postDelayed({ gatt.discoverServices() }, 500)
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
Log.d(TAG, "Disconnected (status=$status)")
|
||||
this@BleScaleManager.gatt?.close()
|
||||
this@BleScaleManager.gatt = null
|
||||
connectedDeviceName = ""
|
||||
mainHandler.post { listener.onDisconnected() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
mainHandler.post { listener.onError("Servizi GATT non trovati (status=$status)") }
|
||||
return
|
||||
}
|
||||
|
||||
val targetChars = mutableListOf<BluetoothGattCharacteristic>()
|
||||
|
||||
// Priority 1: BLE SIG Weight Scale Service
|
||||
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)
|
||||
?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)
|
||||
?.let { targetChars.add(it) }
|
||||
|
||||
// Priority 2: Common vendor service FFE0 (arboleaf, generic kitchen scales)
|
||||
gatt.getService(BleUuids.FFE0)?.let { svc ->
|
||||
svc.getCharacteristic(BleUuids.FFE1)?.let { targetChars.add(it) }
|
||||
}
|
||||
|
||||
// Priority 3: Common vendor service FFF0
|
||||
gatt.getService(BleUuids.FFF0)?.let { svc ->
|
||||
svc.getCharacteristic(BleUuids.FFF4)?.let { targetChars.add(it) }
|
||||
?: svc.getCharacteristic(BleUuids.FFF1)?.let { targetChars.add(it) }
|
||||
}
|
||||
|
||||
// Priority 4: Acaia coffee scale
|
||||
gatt.getService(BleUuids.ACAIA_SERVICE)?.let { svc ->
|
||||
svc.getCharacteristic(BleUuids.ACAIA_CHAR)?.let { targetChars.add(it) }
|
||||
}
|
||||
|
||||
// Fallback: any notifiable characteristic from remaining services
|
||||
if (targetChars.isEmpty()) {
|
||||
for (service in gatt.services) {
|
||||
if (service.uuid.toString().startsWith("00001800") ||
|
||||
service.uuid.toString().startsWith("00001801")) continue
|
||||
for (char in service.characteristics) {
|
||||
val props = char.properties
|
||||
if ((props and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 ||
|
||||
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) {
|
||||
if (!targetChars.contains(char)) targetChars.add(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetChars.isEmpty()) {
|
||||
mainHandler.post { listener.onError("No weight characteristic found. Make sure it's a BLE kitchen scale.") }
|
||||
return
|
||||
}
|
||||
|
||||
// Battery (optional)
|
||||
gatt.getService(BleUuids.BATTERY_SERVICE)
|
||||
?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)
|
||||
?.let { targetChars.add(it) }
|
||||
|
||||
// Debug: log all discovered services and characteristics
|
||||
val dbg = buildString {
|
||||
append("GATT services (${gatt.services.size}):\n")
|
||||
for (svc in gatt.services) {
|
||||
append(" SVC: ${svc.uuid}\n")
|
||||
for (ch in svc.characteristics) {
|
||||
val p = ch.properties
|
||||
val flags = buildString {
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) append("N")
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) append("I")
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_READ != 0) append("R")
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) append("W")
|
||||
}
|
||||
append(" CHAR: ${ch.uuid} [$flags]\n")
|
||||
}
|
||||
}
|
||||
append("Subscribed to ${targetChars.size} characteristics")
|
||||
}
|
||||
mainHandler.post { listener.onDebugEvent(dbg) }
|
||||
|
||||
// Save device for auto-reconnect
|
||||
try { gatt.device?.address?.let { saveDeviceAddress(it) } } catch (_: SecurityException) {}
|
||||
|
||||
pendingSubscriptions.clear()
|
||||
pendingSubscriptions.addAll(targetChars)
|
||||
|
||||
val deviceName = try { gatt.device?.name ?: "Scale" } catch (e: SecurityException) { "Scale" }
|
||||
connectedDeviceName = deviceName
|
||||
mainHandler.post { listener.onConnected(deviceName) }
|
||||
|
||||
// Subscribe one at a time (Android BLE requires sequential descriptor writes)
|
||||
subscribeNext(gatt)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
// Subscribe to the next characteristic
|
||||
subscribeNext(gatt)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
) {
|
||||
val data = characteristic.value ?: return
|
||||
processCharacteristicData(characteristic, data)
|
||||
}
|
||||
|
||||
// Android 13+ override
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray,
|
||||
) {
|
||||
processCharacteristicData(characteristic, value)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCharacteristicRead(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int,
|
||||
) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS && characteristic.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
|
||||
val level = characteristic.value?.firstOrNull()?.toInt()?.and(0xFF)
|
||||
if (level != null) mainHandler.post { listener.onBatteryReceived(level) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private fun subscribeNext(gatt: BluetoothGatt) {
|
||||
val char = pendingSubscriptions.removeFirstOrNull() ?: return
|
||||
|
||||
// Battery characteristic — read once instead of notify
|
||||
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR) {
|
||||
try { gatt.readCharacteristic(char) } catch (e: SecurityException) { /* ignore */ }
|
||||
return
|
||||
}
|
||||
|
||||
val props = char.properties
|
||||
val notifyType = when {
|
||||
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 ->
|
||||
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
|
||||
else -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
}
|
||||
|
||||
try {
|
||||
gatt.setCharacteristicNotification(char, true)
|
||||
val descriptor = char.getDescriptor(CCCD_UUID) ?: run {
|
||||
// No CCCD — skip and try next
|
||||
subscribeNext(gatt)
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeDescriptor(descriptor, notifyType)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
descriptor.value = notifyType
|
||||
@Suppress("DEPRECATION")
|
||||
gatt.writeDescriptor(descriptor)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "SecurityException enabling notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processCharacteristicData(char: BluetoothGattCharacteristic, data: ByteArray) {
|
||||
// Battery level
|
||||
if (char.uuid == BleUuids.BATTERY_LEVEL_CHAR && data.isNotEmpty()) {
|
||||
val level = data[0].toInt() and 0xFF
|
||||
mainHandler.post { listener.onBatteryReceived(level) }
|
||||
return
|
||||
}
|
||||
|
||||
// Debug: log raw bytes received
|
||||
val hex = data.joinToString(" ") { "%02X".format(it) }
|
||||
mainHandler.post { listener.onDebugEvent("📡 ${char.uuid}\n HEX [${data.size}B]: $hex") }
|
||||
|
||||
// Parse weight data
|
||||
val reading = ScaleProtocol.parse(char, data) { msg ->
|
||||
mainHandler.post { listener.onDebugEvent(msg) }
|
||||
}
|
||||
if (reading != null && reading.value > 0f) {
|
||||
mainHandler.post { listener.onWeightReceived(reading) }
|
||||
} else {
|
||||
val rawDump = data.mapIndexed { i, b ->
|
||||
val v = b.toInt() and 0xFF
|
||||
val h = "%02X".format(v)
|
||||
"[$i]=$v(0x$h)"
|
||||
}.joinToString(" ")
|
||||
mainHandler.post { listener.onDebugEvent("\u26a0\ufe0f Weight not decoded\n RAW: $rawDump") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package it.dadaloop.evershelf.scalegate
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Centralized error reporter for EverShelf Scale Gateway.
|
||||
*
|
||||
* Unlike the Kiosk (which relays errors through the EverShelf PHP backend),
|
||||
* the Scale Gateway has no knowledge of the EverShelf server URL, so it
|
||||
* calls the GitHub Issues REST API directly.
|
||||
*
|
||||
* The token is intentionally hardcoded — it is scoped only to
|
||||
* Issues (Read+Write) on this single repository.
|
||||
*
|
||||
* Usage:
|
||||
* ErrorReporter.init(applicationContext)
|
||||
* ErrorReporter.report(exception, "methodName", mapOf("extra" to "info"))
|
||||
* ErrorReporter.reportMessage("ble-disconnect", "Scale disconnected after 3 retries")
|
||||
*/
|
||||
object ErrorReporter {
|
||||
|
||||
private const val TAG = "ScaleGWErrorReporter"
|
||||
|
||||
// ── XOR-obfuscated GitHub token (scoped: Issues R+W on dadaloop82/EverShelf) ──
|
||||
// Stored encoded so the literal token string never appears in source or git history.
|
||||
private const val GH_TOKEN_ENC = "23580718460c2c444031290243627e7971622b29035e2a647726407d194f61440b6e05246a0c067c79730e77114b774501730043433d1866682225511b5443417170444443142941673c4046086c05737363293e7821006e470a466a1d"
|
||||
private const val GH_TOKEN_KEY = "D1sp3ns4!Ev3r#26"
|
||||
private const val GH_REPO = "dadaloop82/EverShelf"
|
||||
|
||||
private var _ghTokenCache: String? = null
|
||||
private fun ghToken(): String {
|
||||
_ghTokenCache?.let { return it }
|
||||
val enc = GH_TOKEN_ENC.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
val key = GH_TOKEN_KEY
|
||||
val out = String(ByteArray(enc.size) { i -> (enc[i].toInt() xor key[i % key.length].code).toByte() })
|
||||
_ghTokenCache = out
|
||||
return out
|
||||
}
|
||||
|
||||
// SharedPreferences key for pending (unsent) crash reports
|
||||
private const val PREFS_NAME = "evershelf_scalegw_errors"
|
||||
private const val KEY_PENDING = "pending_crash_json"
|
||||
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private val sentFingerprints = mutableSetOf<String>()
|
||||
|
||||
private var appVersion: String = "unknown"
|
||||
private var deviceInfo: String = ""
|
||||
private lateinit var appContext: Context
|
||||
|
||||
/**
|
||||
* Call once in MainActivity.onCreate() or Application.onCreate().
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (Android ${Build.VERSION.RELEASE})"
|
||||
try {
|
||||
val pi = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
appVersion = pi.versionName ?: "unknown"
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// Send any crash report that was saved from the previous session
|
||||
sendPendingCrash()
|
||||
|
||||
// Install global UncaughtExceptionHandler
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
val crash = buildPayload(
|
||||
type = "uncaught-exception",
|
||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
||||
stack = throwable.stackTraceToString(),
|
||||
context = mapOf("thread" to thread.name)
|
||||
)
|
||||
// Save to prefs first (in case network POST fails before process dies)
|
||||
savePendingCrash(crash)
|
||||
// Try immediate send (synchronous — we're already off main thread in the handler)
|
||||
postToGitHub(crash)
|
||||
clearPendingCrash()
|
||||
} catch (_: Exception) {}
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** Report a caught [Throwable] asynchronously. */
|
||||
fun report(throwable: Throwable, location: String = "", extra: Map<String, Any?> = emptyMap()) {
|
||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
||||
if (location.isNotEmpty()) ctx["location"] = location
|
||||
ctx.putAll(extra)
|
||||
enqueue(
|
||||
type = "scale-exception",
|
||||
message = "${throwable.javaClass.simpleName}: ${throwable.message}",
|
||||
stack = throwable.stackTraceToString(),
|
||||
context = ctx
|
||||
)
|
||||
}
|
||||
|
||||
/** Report a non-exception event (e.g. BLE disconnect, WebSocket error). */
|
||||
fun reportMessage(type: String, message: String, extra: Map<String, Any?> = emptyMap()) {
|
||||
val ctx = mutableMapOf<String, Any?>("device" to deviceInfo)
|
||||
ctx.putAll(extra)
|
||||
enqueue(type = type, message = message, stack = "", context = ctx)
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun fingerprint(type: String, message: String) =
|
||||
"${type}:${message.take(120)}".hashCode().toString(16)
|
||||
|
||||
private fun enqueue(type: String, message: String, stack: String, context: Map<String, Any?>) {
|
||||
val fp = fingerprint(type, message)
|
||||
synchronized(sentFingerprints) {
|
||||
if (!sentFingerprints.add(fp)) return
|
||||
}
|
||||
val payload = buildPayload(type, message, stack, context)
|
||||
executor.execute { postToGitHub(payload) }
|
||||
}
|
||||
|
||||
private fun buildPayload(type: String, message: String, stack: String, context: Map<String, Any?>): JSONObject {
|
||||
val ctxJson = JSONObject()
|
||||
context.forEach { (k, v) -> ctxJson.put(k, v) }
|
||||
return JSONObject().apply {
|
||||
put("source", "scale")
|
||||
put("type", type)
|
||||
put("message", message)
|
||||
put("stack", stack)
|
||||
put("context", ctxJson)
|
||||
put("version", appVersion)
|
||||
put("user_agent", "EverShelf-ScaleGateway/$appVersion (Android ${Build.VERSION.RELEASE}; ${Build.MODEL})")
|
||||
put("ts", SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()))
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist crash payload to SharedPreferences so it survives a process kill. */
|
||||
private fun savePendingCrash(payload: JSONObject) {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_PENDING, payload.toString()).apply()
|
||||
}
|
||||
|
||||
private fun clearPendingCrash() {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().remove(KEY_PENDING).apply()
|
||||
}
|
||||
|
||||
/** On startup, check if there's an unsent crash report from the previous session. */
|
||||
private fun sendPendingCrash() {
|
||||
val json = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_PENDING, null) ?: return
|
||||
clearPendingCrash() // remove before sending to prevent re-sending on next crash
|
||||
executor.execute {
|
||||
try {
|
||||
val payload = JSONObject(json)
|
||||
// Tag it as a "survived-crash" so we know it was saved and retried
|
||||
payload.put("type", "uncaught-exception-survived")
|
||||
payload.put("note", "Sent on next launch after crash")
|
||||
postToGitHub(payload)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHub Issue (or add a comment to an existing one with the same fingerprint).
|
||||
* Uses the GitHub Issues Search API to deduplicate.
|
||||
*/
|
||||
private fun postToGitHub(payload: JSONObject) {
|
||||
val source = payload.optString("source", "scale")
|
||||
val type = payload.optString("type", "error")
|
||||
val message = payload.optString("message", "")
|
||||
val stack = payload.optString("stack", "")
|
||||
val version = payload.optString("version", "")
|
||||
val ua = payload.optString("user_agent", "")
|
||||
val ts = payload.optString("ts", "")
|
||||
val ctxJson = payload.optJSONObject("context") ?: JSONObject()
|
||||
|
||||
val fp = fingerprint(type, message)
|
||||
|
||||
// ── 1. Search for existing open issue ──────────────────────────────
|
||||
val searchQ = "repo:$GH_REPO is:issue is:open label:auto-report \"fp:$fp\" in:body"
|
||||
val searchUrl = "https://api.github.com/search/issues?q=${java.net.URLEncoder.encode(searchQ, "UTF-8")}&per_page=1"
|
||||
val searchResult = ghGet(searchUrl) ?: JSONObject()
|
||||
val existingNumber = searchResult.optJSONArray("items")?.optJSONObject(0)?.optInt("number", 0)?.takeIf { it > 0 }
|
||||
|
||||
// ── 2. Build body ─────────────────────────────────────────────────
|
||||
val ctxMd = if (ctxJson.length() > 0) "\n**Context:**\n```json\n${ctxJson.toString(2)}\n```\n" else ""
|
||||
val stackMd = if (stack.isNotEmpty()) "\n**Stack trace:**\n```\n$stack\n```\n" else ""
|
||||
|
||||
if (existingNumber != null) {
|
||||
// Comment on existing issue
|
||||
val body = "### 🔁 Recurrence — $ts\n**Source:** `$source` | **Type:** `$type`\n**UA:** `$ua`\n$ctxMd$stackMd\n---\n_fp:${fp}_"
|
||||
ghPost("https://api.github.com/repos/$GH_REPO/issues/$existingNumber/comments", JSONObject().put("body", body))
|
||||
} else {
|
||||
// Create new issue
|
||||
val shortMsg = if (message.length > 70) "${message.take(70)}…" else message
|
||||
val title = "[SCALE] $shortMsg"
|
||||
val body = "## 🚨 Automatic Error Report\n\n**Source:** `$source` \n**Type:** `$type` \n**Reported at:** $ts \n**UA:** `$ua` \n**Version:** `$version`\n\n**Error message:**\n> $message\n$stackMd$ctxMd\n---\n<!-- auto-report fp:$fp -->\n_This issue was created automatically by EverShelf Scale Gateway error reporter. fp:`${fp}`_"
|
||||
ghPost(
|
||||
"https://api.github.com/repos/$GH_REPO/issues",
|
||||
JSONObject()
|
||||
.put("title", title)
|
||||
.put("body", body)
|
||||
.put("labels", JSONArray().put("auto-report").put("scale-error"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ghGet(url: String): JSONObject? = try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.setRequestProperty("Authorization", "token ${ghToken()}")
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
val raw = BufferedReader(InputStreamReader(conn.inputStream)).readText()
|
||||
conn.disconnect()
|
||||
JSONObject(raw)
|
||||
} catch (e: Exception) { Log.w(TAG, "ghGet failed: ${e.message}"); null }
|
||||
|
||||
private fun ghPost(url: String, payload: JSONObject): Int = try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Authorization", "token ${ghToken()}")
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
||||
conn.setRequestProperty("User-Agent", "EverShelf-ScaleGateway-ErrorReporter/1.0")
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
conn.doOutput = true
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(payload.toString()) }
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
Log.d(TAG, "ghPost $url → HTTP $code")
|
||||
code
|
||||
} catch (e: Exception) { Log.w(TAG, "ghPost failed: ${e.message}"); -1 }
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package it.dadaloop.evershelf.scalegate
|
||||
|
||||
import android.util.Log
|
||||
import org.java_websocket.WebSocket
|
||||
import org.java_websocket.handshake.ClientHandshake
|
||||
import org.java_websocket.server.WebSocketServer
|
||||
import org.json.JSONObject
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.Collections
|
||||
|
||||
private const val TAG = "GatewayWsServer"
|
||||
|
||||
/**
|
||||
* Callbacks for the WebSocket server, dispatched on the server's internal thread.
|
||||
* The caller (MainActivity) is responsible for switching to the main thread if needed.
|
||||
*/
|
||||
interface ServerEventListener {
|
||||
fun onClientConnected(address: String)
|
||||
fun onClientDisconnected(address: String)
|
||||
fun onClientRequestedWeight()
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket server that exposes smart-scale data to EverShelf running in a browser.
|
||||
*
|
||||
* Message protocol (JSON):
|
||||
*
|
||||
* Server -> Client:
|
||||
* {"type":"status","state":"connected"|"disconnected","device":"QN-KS","battery":80}
|
||||
* {"type":"weight","value":17.0,"unit":"g","stable":true,"timestamp":1712345678000}
|
||||
* {"type":"pong"}
|
||||
*
|
||||
* Client → Server:
|
||||
* {"type":"get_status"} → server responds with current status message
|
||||
* {"type":"get_weight"} → server will push the next stable weight reading
|
||||
* {"type":"ping"} → server responds with {"type":"pong"}
|
||||
*/
|
||||
class GatewayWebSocketServer(
|
||||
port: Int,
|
||||
private val eventListener: ServerEventListener?,
|
||||
) : WebSocketServer(InetSocketAddress(port)) {
|
||||
|
||||
// Thread-safe set of clients waiting for the next stable weight reading
|
||||
private val pendingWeightRequests: MutableSet<WebSocket> =
|
||||
Collections.synchronizedSet(mutableSetOf())
|
||||
|
||||
// Last known scale state (to send to new clients immediately)
|
||||
@Volatile private var lastStatusJson: String = buildStatusJson("disconnected", null, null)
|
||||
@Volatile private var lastWeightJson: String? = null
|
||||
|
||||
// ─── Server lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
override fun onStart() {
|
||||
Log.i(TAG, "WebSocket server started on port ${address.port}")
|
||||
connectionLostTimeout = 30
|
||||
}
|
||||
|
||||
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
|
||||
val addr = conn.remoteSocketAddress?.toString() ?: "?"
|
||||
Log.d(TAG, "Client connected: $addr")
|
||||
|
||||
// Immediately send current status so the web app knows the scale state
|
||||
conn.send(lastStatusJson)
|
||||
lastWeightJson?.let { conn.send(it) }
|
||||
|
||||
eventListener?.onClientConnected(addr)
|
||||
}
|
||||
|
||||
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
|
||||
val addr = conn.remoteSocketAddress?.toString() ?: "?"
|
||||
Log.d(TAG, "Client disconnected: $addr (code=$code)")
|
||||
pendingWeightRequests.remove(conn)
|
||||
eventListener?.onClientDisconnected(addr)
|
||||
}
|
||||
|
||||
override fun onMessage(conn: WebSocket, message: String) {
|
||||
try {
|
||||
val json = JSONObject(message)
|
||||
when (json.optString("type")) {
|
||||
"ping" -> conn.send("""{"type":"pong"}""")
|
||||
"get_status" -> conn.send(lastStatusJson)
|
||||
"get_weight" -> {
|
||||
// Add to pending set; next stable weight will be sent to this client
|
||||
pendingWeightRequests.add(conn)
|
||||
eventListener?.onClientRequestedWeight()
|
||||
// If we already have a recent weight, send it immediately
|
||||
lastWeightJson?.let { conn.send(it) }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Malformed message: $message")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(conn: WebSocket?, ex: Exception) {
|
||||
Log.e(TAG, "WebSocket error on ${conn?.remoteSocketAddress}", ex)
|
||||
ErrorReporter.report(ex, "GatewayWebSocketServer.onError",
|
||||
mapOf("remote_addr" to (conn?.remoteSocketAddress?.toString() ?: "null")))
|
||||
}
|
||||
|
||||
// ─── Publishing API ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Broadcast scale connection status to all connected WebSocket clients.
|
||||
*/
|
||||
fun publishStatus(state: String, deviceName: String?, battery: Int?) {
|
||||
lastStatusJson = buildStatusJson(state, deviceName, battery)
|
||||
broadcast(lastStatusJson)
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a weight reading to all clients.
|
||||
* If [stable] is true, also fulfil pending on-demand weight requests.
|
||||
*/
|
||||
fun publishWeight(value: Float, unit: String, stable: Boolean, battery: Int? = null) {
|
||||
val json = buildWeightJson(value, unit, stable)
|
||||
lastWeightJson = json
|
||||
broadcast(json)
|
||||
|
||||
if (stable) {
|
||||
synchronized(pendingWeightRequests) {
|
||||
// Clients that requested on-demand readings are already served by broadcast;
|
||||
// just clear the pending set.
|
||||
pendingWeightRequests.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── JSON builders ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun buildStatusJson(state: String, device: String?, battery: Int?): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("type", "status")
|
||||
obj.put("state", state)
|
||||
if (device != null) obj.put("device", device)
|
||||
if (battery != null) obj.put("battery", battery)
|
||||
return obj.toString()
|
||||
}
|
||||
|
||||
private fun buildWeightJson(value: Float, unit: String, stable: Boolean): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("type", "weight")
|
||||
// Round to 1 decimal to avoid floating point noise (e.g. 17.000001)
|
||||
val rounded = Math.round(value * 10f) / 10.0
|
||||
obj.put("value", rounded)
|
||||
obj.put("unit", unit)
|
||||
obj.put("stable", stable)
|
||||
obj.put("timestamp", System.currentTimeMillis())
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
package it.dadaloop.evershelf.scalegate
|
||||
|
||||
import android.Manifest
|
||||
import android.app.DownloadManager
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.app.PendingIntent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import it.dadaloop.evershelf.scalegate.databinding.ActivityMainBinding
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import org.json.JSONObject
|
||||
|
||||
private const val WS_PORT = 8765
|
||||
|
||||
class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var bleManager: BleScaleManager
|
||||
private var wsServer: GatewayWebSocketServer? = null
|
||||
|
||||
private val devices = mutableListOf<BleDeviceInfo>()
|
||||
private lateinit var deviceAdapter: DeviceAdapter
|
||||
|
||||
private var batteryLevel: Int? = null
|
||||
private val debugLines = mutableListOf<String>()
|
||||
private var debugVisible = false
|
||||
private var lastDebugUpdate = 0L
|
||||
private val debugTimeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
private var isAutoReconnecting = false
|
||||
// Update banner
|
||||
private var pendingApkDownloadUrl = ""
|
||||
private var pendingInstallFile: java.io.File? = null
|
||||
private companion object {
|
||||
const val MAX_DEBUG_LINES = 150
|
||||
const val DEBUG_THROTTLE_MS = 200L
|
||||
const val GITHUB_RELEASES_API = "https://api.github.com/repos/dadaloop82/EverShelf/releases/latest"
|
||||
const val APK_DOWNLOAD_URL = "https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk"
|
||||
}
|
||||
|
||||
// ─── Permission launcher ───────────────────────────────────────────────────
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { granted ->
|
||||
if (granted.values.all { it }) {
|
||||
startGatewayServer()
|
||||
} else {
|
||||
showDialog("Missing permissions",
|
||||
"The app requires Bluetooth and Location permissions to function.")
|
||||
}
|
||||
}
|
||||
|
||||
private val enableBtLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == RESULT_OK) checkPermissionsAndStart()
|
||||
else showDialog("Bluetooth required", "Please enable Bluetooth to use the gateway.")
|
||||
}
|
||||
|
||||
/** Returns from ACTION_MANAGE_UNKNOWN_APP_SOURCES — retry the download. */
|
||||
private val installPermLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { _ ->
|
||||
val url = pendingApkDownloadUrl
|
||||
if (url.isNotEmpty()) triggerApkDownload(url)
|
||||
}
|
||||
|
||||
/** Returns from system installer dialog — if not OK the install failed (signature conflict?). */
|
||||
private val installConfirmLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode != RESULT_OK) {
|
||||
val f = pendingInstallFile
|
||||
if (f != null && f.exists()) {
|
||||
runOnUiThread {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("⚠️ Installazione non riuscita")
|
||||
.setMessage("Se hai visto un errore di conflitto firma, devi disinstallare la versione precedente.\n\nDisinstalla ora? L'installazione ripartirà automaticamente.")
|
||||
.setPositiveButton("Disinstalla") { _, _ ->
|
||||
uninstallLauncher.launch(
|
||||
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
|
||||
)
|
||||
}
|
||||
.setNegativeButton("Annulla", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns from uninstall screen — auto-retry the install with the saved APK file. */
|
||||
private val uninstallLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { _ ->
|
||||
val f = pendingInstallFile
|
||||
if (f != null && f.exists()) installApk(f)
|
||||
}
|
||||
|
||||
// ─── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
bleManager = BleScaleManager(this, this)
|
||||
|
||||
// Initialise error reporter early so the UncaughtExceptionHandler is installed
|
||||
// and any pending crash from a previous session is sent
|
||||
ErrorReporter.init(this)
|
||||
|
||||
deviceAdapter = DeviceAdapter(devices) { info ->
|
||||
bleManager.connect(info.device)
|
||||
}
|
||||
binding.rvDevices.apply {
|
||||
layoutManager = LinearLayoutManager(this@MainActivity)
|
||||
adapter = deviceAdapter
|
||||
}
|
||||
|
||||
binding.btnScan.setOnClickListener { startScanIfPermitted() }
|
||||
binding.btnDisconnect.setOnClickListener {
|
||||
bleManager.disconnect()
|
||||
updateUiDisconnected()
|
||||
}
|
||||
binding.btnDebug.setOnClickListener {
|
||||
debugVisible = !debugVisible
|
||||
binding.svDebugLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
|
||||
binding.btnCopyLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
|
||||
binding.btnShareLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
|
||||
binding.btnDebug.text = if (debugVisible) "\uD83D\uDC1B Hide Debug" else "\uD83D\uDC1B Debug"
|
||||
}
|
||||
binding.btnCopyLog.setOnClickListener {
|
||||
val log = debugLines.joinToString("\n")
|
||||
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Scale Log", log))
|
||||
Toast.makeText(this, "Log copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
binding.btnShareLog.setOnClickListener {
|
||||
val log = debugLines.joinToString("\n")
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_SUBJECT, "EverShelf Scale Gateway - Debug Log")
|
||||
putExtra(Intent.EXTRA_TEXT, log)
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, "Share log"))
|
||||
}
|
||||
|
||||
// Show app version
|
||||
try {
|
||||
val pInfo = packageManager.getPackageInfo(packageName, 0)
|
||||
binding.tvVersion.text = "v${pInfo.versionName} (${pInfo.longVersionCode})"
|
||||
} catch (_: Exception) { }
|
||||
|
||||
updateGatewayUrl()
|
||||
checkPermissionsAndStart()
|
||||
|
||||
// Wire update banner buttons
|
||||
binding.btnDismissUpdate.setOnClickListener { binding.updateBanner.visibility = View.GONE }
|
||||
binding.btnInstallUpdate.setOnClickListener { triggerApkDownload(pendingApkDownloadUrl) }
|
||||
|
||||
// Check for a newer release (background thread, at most once every 6 h)
|
||||
checkForUpdates()
|
||||
|
||||
// Auto-connect: if we have a saved device, start scanning with auto-connect enabled
|
||||
if (bleManager.getSavedDeviceAddress() != null) {
|
||||
binding.tvScanHint.visibility = View.VISIBLE
|
||||
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale\u2026"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
bleManager.disconnect()
|
||||
wsServer?.stop(1000)
|
||||
}
|
||||
|
||||
// ─── Permissions & startup ─────────────────────────────────────────────────
|
||||
|
||||
private fun checkPermissionsAndStart() {
|
||||
val required = buildList {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
add(Manifest.permission.BLUETOOTH_SCAN)
|
||||
add(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
add(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
}
|
||||
val missing = required.filter {
|
||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
when {
|
||||
missing.isNotEmpty() -> permissionLauncher.launch(missing.toTypedArray())
|
||||
!isBluetoothEnabled() -> enableBtLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
|
||||
else -> startGatewayServer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBluetoothEnabled(): Boolean {
|
||||
val adapter = android.bluetooth.BluetoothManager::class.java.let {
|
||||
getSystemService(it)
|
||||
} as? android.bluetooth.BluetoothManager
|
||||
return adapter?.adapter?.isEnabled == true
|
||||
}
|
||||
|
||||
private fun startScanIfPermitted() {
|
||||
if (!bleManager.hasRequiredPermissions()) {
|
||||
checkPermissionsAndStart()
|
||||
return
|
||||
}
|
||||
devices.clear()
|
||||
deviceAdapter.notifyDataSetChanged()
|
||||
debugLines.clear()
|
||||
binding.tvDebugLog.text = ""
|
||||
binding.tvScanHint.visibility = View.VISIBLE
|
||||
binding.tvScanHint.text = "Scanning for BLE scales\u2026"
|
||||
binding.btnScan.isEnabled = false
|
||||
bleManager.enableAutoConnect()
|
||||
isAutoReconnecting = false // manual scan — stop any pending auto-reconnect cycle
|
||||
bleManager.startScan()
|
||||
}
|
||||
|
||||
// ─── WebSocket gateway ─────────────────────────────────────────────────────
|
||||
|
||||
private fun startGatewayServer() {
|
||||
if (wsServer != null) return
|
||||
try {
|
||||
wsServer = GatewayWebSocketServer(WS_PORT, this)
|
||||
wsServer!!.start()
|
||||
updateGatewayUrl()
|
||||
binding.tvGatewayStatus.text = "\u2705 Gateway active on port $WS_PORT"
|
||||
} catch (e: Exception) {
|
||||
binding.tvGatewayStatus.text = "\u274C Failed to start gateway: ${e.message}"
|
||||
ErrorReporter.report(e, "startGatewayServer", mapOf("port" to WS_PORT))
|
||||
}
|
||||
|
||||
// Auto-scan if there's a saved device
|
||||
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
|
||||
bleManager.enableAutoConnect()
|
||||
bleManager.startScan()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateGatewayUrl() {
|
||||
val ip = getLocalIpAddress() ?: "—"
|
||||
val url = "ws://$ip:$WS_PORT"
|
||||
binding.tvGatewayUrl.text = url
|
||||
binding.tvGatewayUrlHint.text = "Paste this URL in EverShelf \u2192 Settings \u2192 Smart Scale"
|
||||
binding.btnCopyUrl.setOnClickListener {
|
||||
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Gateway URL", url))
|
||||
binding.btnCopyUrl.text = "\u2705 Copied!"
|
||||
binding.btnCopyUrl.postDelayed({ binding.btnCopyUrl.text = "\uD83D\uDCCB Copy URL" }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BleScaleListener ─────────────────────────────────────────────────────
|
||||
|
||||
override fun onDeviceFound(info: BleDeviceInfo) {
|
||||
if (devices.none { it.device.address == info.device.address }) {
|
||||
// Insert keeping descending scaleScore order (scale-likely devices first)
|
||||
val insertAt = devices.indexOfFirst { it.scaleScore < info.scaleScore }
|
||||
.let { if (it < 0) devices.size else it }
|
||||
devices.add(insertAt, info)
|
||||
deviceAdapter.notifyItemInserted(insertAt)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnecting(device: BluetoothDevice) {
|
||||
val name = try { device.name ?: device.address } catch (e: SecurityException) { device.address }
|
||||
binding.tvScaleStatus.text = "\u23f3 Connecting to $name\u2026"
|
||||
binding.tvWeight.text = "— — —"
|
||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_orange_light))
|
||||
}
|
||||
|
||||
override fun onConnected(deviceName: String) {
|
||||
isAutoReconnecting = false
|
||||
binding.tvScaleStatus.text = "\u2705 Connected: $deviceName"
|
||||
binding.tvWeight.text = "Waiting for weight\u2026"
|
||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_green_light))
|
||||
binding.btnDisconnect.visibility = View.VISIBLE
|
||||
binding.rvDevices.visibility = View.GONE
|
||||
binding.btnScan.visibility = View.GONE
|
||||
binding.tvScanHint.visibility = View.GONE
|
||||
wsServer?.publishStatus("connected", deviceName, batteryLevel)
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
wsServer?.publishStatus("disconnected", null, null)
|
||||
updateUiDisconnected()
|
||||
// Auto-reconnect: if a saved device exists, restart scan after a short delay.
|
||||
// This handles the scale turning off by itself (auto-off) — when it powers
|
||||
// back on it will start advertising again and we will pick it up.
|
||||
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
|
||||
isAutoReconnecting = true
|
||||
binding.tvScanHint.visibility = View.VISIBLE
|
||||
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale in 5 s\u2026"
|
||||
binding.root.postDelayed({
|
||||
if (!bleManager.isConnected && isAutoReconnecting) {
|
||||
bleManager.enableAutoConnect()
|
||||
bleManager.startScan()
|
||||
}
|
||||
}, 5_000L)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWeightReceived(reading: WeightReading) {
|
||||
val displayValue = if (reading.value % 1f == 0f) reading.value.toInt().toString()
|
||||
else "%.1f".format(reading.value)
|
||||
binding.tvWeight.text = "$displayValue ${reading.unit}"
|
||||
|
||||
if (reading.stable) {
|
||||
binding.tvWeightHint.text = "\u2713 Stable reading"
|
||||
} else {
|
||||
binding.tvWeightHint.text = "\u23f3 Measuring\u2026"
|
||||
}
|
||||
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, batteryLevel)
|
||||
}
|
||||
|
||||
override fun onBatteryReceived(level: Int) {
|
||||
batteryLevel = level
|
||||
binding.tvBattery.text = "🔋 $level%"
|
||||
binding.tvBattery.visibility = View.VISIBLE
|
||||
wsServer?.publishStatus("connected", binding.tvScaleStatus.text.toString()
|
||||
.removePrefix("\u2705 Connected: "), level)
|
||||
}
|
||||
|
||||
override fun onError(message: String) {
|
||||
binding.tvScaleStatus.text = "❌ $message"
|
||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_red_light))
|
||||
ErrorReporter.reportMessage(
|
||||
type = "ble-error",
|
||||
message = message,
|
||||
extra = mapOf("connected_device" to (bleManager.getSavedDeviceAddress() ?: "none"))
|
||||
)
|
||||
}
|
||||
|
||||
override fun onScanStopped() {
|
||||
binding.btnScan.isEnabled = true
|
||||
if (isAutoReconnecting && !bleManager.isConnected && bleManager.getSavedDeviceAddress() != null) {
|
||||
// Scale not found yet — retry scan after 10 s indefinitely until reconnected
|
||||
binding.tvScanHint.visibility = View.VISIBLE
|
||||
binding.tvScanHint.text = "\uD83D\uDD04 Bilancia non trovata, riprovo tra 10 s\u2026"
|
||||
binding.root.postDelayed({
|
||||
if (!bleManager.isConnected && isAutoReconnecting) {
|
||||
binding.tvScanHint.text = "\uD83D\uDD04 Cerco la bilancia\u2026"
|
||||
bleManager.enableAutoConnect()
|
||||
bleManager.startScan()
|
||||
}
|
||||
}, 10_000L)
|
||||
} else if (devices.isEmpty()) {
|
||||
binding.tvScanHint.text = "No scale found. Make sure it's on, then scan again."
|
||||
} else {
|
||||
binding.tvScanHint.text = "Tap a scale to connect."
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDebugEvent(message: String) {
|
||||
runOnUiThread {
|
||||
val ts = debugTimeFmt.format(Date())
|
||||
debugLines.add("[$ts] $message")
|
||||
// Keep only last MAX_DEBUG_LINES
|
||||
while (debugLines.size > MAX_DEBUG_LINES) debugLines.removeAt(0)
|
||||
// Throttle UI updates to avoid freezing
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastDebugUpdate >= DEBUG_THROTTLE_MS) {
|
||||
lastDebugUpdate = now
|
||||
binding.tvDebugLog.text = debugLines.joinToString("\n")
|
||||
if (debugVisible) {
|
||||
binding.svDebugLog.post { binding.svDebugLog.fullScroll(View.FOCUS_DOWN) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ServerEventListener ──────────────────────────────────────────────────
|
||||
|
||||
override fun onClientConnected(address: String) {
|
||||
runOnUiThread {
|
||||
binding.tvClientCount.text = "\uD83C\uDF10 Client connected: $address"
|
||||
binding.tvClientCount.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClientDisconnected(address: String) {
|
||||
runOnUiThread {
|
||||
binding.tvClientCount.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClientRequestedWeight() { /* Nothing extra needed */ }
|
||||
|
||||
// ─── UI helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private fun updateUiDisconnected() {
|
||||
binding.tvScaleStatus.text = "\u26a1 Ready \u2014 scan for a scale"
|
||||
binding.tvWeight.text = "— — —"
|
||||
binding.tvWeightHint.text = ""
|
||||
binding.tvBattery.visibility = View.GONE
|
||||
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.darker_gray))
|
||||
binding.btnDisconnect.visibility = View.GONE
|
||||
binding.rvDevices.visibility = View.VISIBLE
|
||||
binding.btnScan.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun getLocalIpAddress(): String? {
|
||||
return try {
|
||||
NetworkInterface.getNetworkInterfaces().toList()
|
||||
.flatMap { it.inetAddresses.toList() }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.firstOrNull { !it.isLoopbackAddress }
|
||||
?.hostAddress
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
|
||||
private fun showDialog(title: String, message: String) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
// ─── Update check ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun checkForUpdates() {
|
||||
Thread {
|
||||
try {
|
||||
val conn = java.net.URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
conn.connectTimeout = 5000
|
||||
conn.readTimeout = 5000
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
conn.disconnect()
|
||||
val json = JSONObject(body)
|
||||
val latestTag = json.optString("tag_name", "").ifEmpty { return@Thread }
|
||||
val current = try { packageManager.getPackageInfo(packageName, 0).versionName ?: "" } catch (_: Exception) { "" }
|
||||
val norm = { v: String -> v.trimStart('v') }
|
||||
val isSemver = latestTag.trimStart('v').matches(Regex("\\d+\\.\\d+.*"))
|
||||
|
||||
// Find scale-gateway APK in release assets
|
||||
var apkUrl = ""
|
||||
val assets = json.optJSONArray("assets")
|
||||
if (assets != null) {
|
||||
for (i in 0 until assets.length()) {
|
||||
val a = assets.getJSONObject(i)
|
||||
val name = a.optString("name", "").lowercase()
|
||||
val url = a.optString("browser_download_url", "")
|
||||
if ((name.contains("gateway") || name.contains("scale")) && url.isNotEmpty()) {
|
||||
apkUrl = url; break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only show banner if the release actually contains our APK
|
||||
if (apkUrl.isEmpty()) return@Thread
|
||||
|
||||
// Proper semver comparison: only update if remote is strictly newer
|
||||
fun semverNewer(remote: String, local: String): Boolean {
|
||||
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
|
||||
val len = maxOf(r.size, l.size)
|
||||
for (i in 0 until len) {
|
||||
val rv = r.getOrElse(i) { 0 }
|
||||
val lv = l.getOrElse(i) { 0 }
|
||||
if (rv != lv) return rv > lv
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (current.isEmpty()) return@Thread
|
||||
if (isSemver && !semverNewer(norm(latestTag), norm(current))) return@Thread
|
||||
|
||||
val label = if (isSemver) "$current → $latestTag" else latestTag
|
||||
val msg = "⬆️ Scale Gateway $label"
|
||||
runOnUiThread { showNativeUpdateBanner(msg, apkUrl) }
|
||||
} catch (_: Exception) {}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun showNativeUpdateBanner(message: String, apkUrl: String) {
|
||||
pendingApkDownloadUrl = apkUrl
|
||||
binding.tvUpdateMessage.text = message
|
||||
binding.updateBanner.visibility = View.VISIBLE
|
||||
binding.updateBanner.postDelayed({ binding.updateBanner.visibility = View.GONE }, 30_000)
|
||||
}
|
||||
|
||||
private fun triggerApkDownload(apkUrl: String) {
|
||||
if (apkUrl.isEmpty()) return
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
!packageManager.canRequestPackageInstalls()) {
|
||||
pendingApkDownloadUrl = apkUrl // remember for retry
|
||||
installPermLauncher.launch(
|
||||
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
|
||||
)
|
||||
Toast.makeText(this, "Abilita 'Installa app sconosciute', poi torna qui", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
// Download to app-private external dir — no storage permission needed
|
||||
val destDir = getExternalFilesDir(null) ?: filesDir
|
||||
val destFile = java.io.File(destDir, "evershelf-scale-update.apk")
|
||||
pendingInstallFile = destFile
|
||||
val dm = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
|
||||
val req = DownloadManager.Request(Uri.parse(apkUrl)).apply {
|
||||
setTitle("EverShelf Scale Gateway — Aggiornamento")
|
||||
setDescription("Scaricamento aggiornamento…")
|
||||
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
setDestinationUri(Uri.fromFile(destFile))
|
||||
setMimeType("application/vnd.android.package-archive")
|
||||
}
|
||||
val downloadId = dm.enqueue(req)
|
||||
Toast.makeText(this, "Download avviato…", Toast.LENGTH_SHORT).show()
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
|
||||
if (id != downloadId) return
|
||||
unregisterReceiver(this)
|
||||
val q = DownloadManager.Query().setFilterById(downloadId)
|
||||
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
|
||||
var ok = false
|
||||
if (c.moveToFirst()) {
|
||||
val status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
ok = (status == DownloadManager.STATUS_SUCCESSFUL)
|
||||
}
|
||||
c.close()
|
||||
if (ok) installApk(destFile)
|
||||
else runOnUiThread {
|
||||
Toast.makeText(this@MainActivity, "Download fallito, riprova", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// RECEIVER_EXPORTED required: ACTION_DOWNLOAD_COMPLETE is sent by the system DownloadManager
|
||||
// (an external process), so NOT_EXPORTED would silently block the broadcast on API 33+.
|
||||
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), RECEIVER_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, "Errore download: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun installApk(file: java.io.File) {
|
||||
if (!file.exists() || file.length() == 0L) {
|
||||
runOnUiThread { Toast.makeText(this, "File APK non trovato", Toast.LENGTH_LONG).show() }
|
||||
return
|
||||
}
|
||||
try {
|
||||
val pi = packageManager.packageInstaller
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
params.setAppPackageName(packageName)
|
||||
val sessionId = pi.createSession(params)
|
||||
pi.openSession(sessionId).use { session ->
|
||||
file.inputStream().use { input ->
|
||||
session.openWrite("package", 0, file.length()).use { out ->
|
||||
input.copyTo(out)
|
||||
session.fsync(out)
|
||||
}
|
||||
}
|
||||
val action = "it.dadaloop.evershelf.scalegate.INSTALL_RESULT_$sessionId"
|
||||
val resultReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
unregisterReceiver(this)
|
||||
val status = intent?.getIntExtra(
|
||||
PackageInstaller.EXTRA_STATUS,
|
||||
PackageInstaller.STATUS_FAILURE
|
||||
) ?: PackageInstaller.STATUS_FAILURE
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
// Use launcher so we get notified if system installer fails
|
||||
@Suppress("DEPRECATION")
|
||||
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
||||
intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
else intent?.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
if (confirmIntent != null) installConfirmLauncher.launch(confirmIntent)
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS ->
|
||||
runOnUiThread { Toast.makeText(this@MainActivity, "✅ Aggiornamento installato", Toast.LENGTH_SHORT).show() }
|
||||
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
|
||||
PackageInstaller.STATUS_FAILURE_CONFLICT -> {
|
||||
runOnUiThread {
|
||||
AlertDialog.Builder(this@MainActivity)
|
||||
.setTitle("⚠️ Conflitto firma APK")
|
||||
.setMessage("L'app installata usa una firma diversa.\n\nDisinstalla la versione precedente: al termine l'installazione riparte automaticamente.")
|
||||
.setPositiveButton("Disinstalla") { _, _ ->
|
||||
uninstallLauncher.launch(
|
||||
Intent(Intent.ACTION_DELETE, android.net.Uri.parse("package:$packageName"))
|
||||
)
|
||||
}
|
||||
.setNegativeButton("Annulla", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val msg = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
?: "status=$status"
|
||||
runOnUiThread { Toast.makeText(this@MainActivity, "Installazione: $msg", Toast.LENGTH_LONG).show() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
||||
RECEIVER_NOT_EXPORTED else 0
|
||||
registerReceiver(resultReceiver, IntentFilter(action), flags)
|
||||
val pi2 = PendingIntent.getBroadcast(
|
||||
this, sessionId,
|
||||
Intent(action).setPackage(packageName),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
session.commit(pi2.intentSender)
|
||||
}
|
||||
Toast.makeText(this, "Installazione in corso…", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread { Toast.makeText(this, "Errore installazione: ${e.message}", Toast.LENGTH_LONG).show() }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── RecyclerView adapter ──────────────────────────────────────────────────
|
||||
|
||||
inner class DeviceAdapter(
|
||||
private val items: List<BleDeviceInfo>,
|
||||
private val onClick: (BleDeviceInfo) -> Unit,
|
||||
) : RecyclerView.Adapter<DeviceAdapter.VH>() {
|
||||
|
||||
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val tvName: TextView = view.findViewById(R.id.tv_device_name)
|
||||
val tvAddr: TextView = view.findViewById(R.id.tv_device_addr)
|
||||
val tvRssi: TextView = view.findViewById(R.id.tv_device_rssi)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_device, parent, false)
|
||||
return VH(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
val info = items[position]
|
||||
holder.tvName.text = info.name
|
||||
holder.tvAddr.text = info.device.address
|
||||
holder.tvRssi.text = info.proximity
|
||||
holder.itemView.setOnClickListener { onClick(info) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package it.dadaloop.evershelf.scalegate
|
||||
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import java.util.UUID
|
||||
|
||||
// --- Data model ---
|
||||
|
||||
/**
|
||||
* A single weight reading from a BLE scale.
|
||||
* [value] is in the scale's current display unit (grams, oz, ml, lb).
|
||||
* [unit] is "g", "oz", "ml", or "lb".
|
||||
*/
|
||||
data class WeightReading(
|
||||
val value: Float,
|
||||
val unit: String,
|
||||
val stable: Boolean,
|
||||
)
|
||||
|
||||
// --- UUIDs ---
|
||||
|
||||
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
object BleUuids {
|
||||
// BLE SIG Weight Scale (some kitchen scales use this)
|
||||
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
|
||||
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// Battery
|
||||
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// Common vendor services used by kitchen scales
|
||||
val FFE0 = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
|
||||
val FFE1 = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb")
|
||||
val FFF0 = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb")
|
||||
val FFF1 = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb")
|
||||
val FFF4 = UUID.fromString("0000fff4-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// Acaia / Brewista coffee scales
|
||||
val ACAIA_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
|
||||
val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
|
||||
|
||||
// QN/Yolanda food scale secondary service (QN-KS, etc.)
|
||||
val QN_AE00 = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
|
||||
val QN_AE02 = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb")
|
||||
}
|
||||
|
||||
// --- Food scale protocol parser ---
|
||||
|
||||
object ScaleProtocol {
|
||||
|
||||
// Plausible kitchen scale range
|
||||
private const val MAX_GRAMS = 15000f
|
||||
private const val MIN_GRAMS = 0.5f // allow tare/small values
|
||||
|
||||
fun resetState() { /* reserved for future use */ }
|
||||
|
||||
fun parse(
|
||||
char: BluetoothGattCharacteristic,
|
||||
data: ByteArray,
|
||||
debug: ((String) -> Unit)? = null,
|
||||
): WeightReading? {
|
||||
if (data.size < 2) {
|
||||
debug?.invoke("skip: packet too short (" + data.size + "B)")
|
||||
return null
|
||||
}
|
||||
|
||||
// UUID-specific parsers
|
||||
when (char.uuid) {
|
||||
BleUuids.WEIGHT_MEASUREMENT_CHAR -> return parseSigWeight(data, debug)
|
||||
}
|
||||
|
||||
// QN/Yolanda food scale (QN-KS, BC-KS, etc.):
|
||||
// 18-byte frame starting with 0x10 0x12 on FFF1
|
||||
if (data.size == 18
|
||||
&& (data[0].toInt() and 0xFF) == 0x10
|
||||
&& (data[1].toInt() and 0xFF) == 0x12) {
|
||||
return parseQNFood(data, debug)
|
||||
}
|
||||
|
||||
return parseGeneric(data, debug)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BLE SIG 0x2A9D Weight Measurement
|
||||
// -------------------------------------------------------------------------
|
||||
private fun parseSigWeight(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
if (data.size < 3) return null
|
||||
val flags = data[0].toInt() and 0xFF
|
||||
val isImperial = (flags and 0x01) != 0
|
||||
val raw = u16le(data, 1)
|
||||
|
||||
return if (isImperial) {
|
||||
val lb = raw * 0.01f
|
||||
debug?.invoke("SIG 2A9D: raw=$raw -> ${lb}lb")
|
||||
if (lb < 0.01f || lb > 33f) null
|
||||
else WeightReading(lb, "lb", stable = true)
|
||||
} else {
|
||||
val g = raw * 5f // 0.005 kg resolution = 5 g/unit
|
||||
debug?.invoke("SIG 2A9D: raw=$raw -> ${g}g")
|
||||
if (g < MIN_GRAMS || g > MAX_GRAMS) null
|
||||
else WeightReading(g, "g", stable = true)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// QN / Yolanda food scale (QN-KS, BC-KS, YolandaKS, ...)
|
||||
//
|
||||
// 18-byte notification on service 0xFFF0, char 0xFFF1:
|
||||
// [0x10][0x12][00][??][unit][02][05][01][flags][w_hi][w_lo][7E][1F][02][58][02][01][crc]
|
||||
// index: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
||||
//
|
||||
// weight = u16BE(data, 9) / 10.0 (0.1-unit resolution)
|
||||
// unit = byte[4]: 0x01=g, 0x02=oz, 0x03=ml(water), 0x04=ml(milk)
|
||||
// stable = bit3 of byte[8] != 0 (0xF8=stable, 0xF0=settling)
|
||||
// crc = sum(bytes[0..16]) mod 256
|
||||
// -------------------------------------------------------------------------
|
||||
private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
// Verify checksum
|
||||
val calc = data.take(17).sumOf { it.toInt() and 0xFF } and 0xFF
|
||||
if (calc != (data[17].toInt() and 0xFF)) {
|
||||
debug?.invoke("QN-KS: CRC mismatch (calc=0x%02X got=0x%02X)".format(calc, data[17].toInt() and 0xFF))
|
||||
return null
|
||||
}
|
||||
|
||||
val rawValue = u16be(data, 9)
|
||||
val stable = (data[8].toInt() and 0x08) != 0
|
||||
val unit = when (data[4].toInt() and 0xFF) {
|
||||
0x01 -> "g"
|
||||
0x02 -> "oz"
|
||||
0x03 -> "ml" // water mode
|
||||
0x04 -> "ml" // milk mode
|
||||
else -> "g"
|
||||
}
|
||||
|
||||
// Resolution is 0.1 unit (e.g. 170 raw = 17.0 g, 195 raw = 19.5 g)
|
||||
val value = rawValue / 10f
|
||||
|
||||
debug?.invoke("QN-KS: ${value}${unit} stable=$stable (raw=$rawValue unit_byte=0x%02X)".format(data[4].toInt() and 0xFF))
|
||||
|
||||
if (rawValue == 0) return null
|
||||
// Convert to grams for range check
|
||||
val valueG = if (unit == "oz") value * 28.3495f else value
|
||||
if (valueG < MIN_GRAMS || valueG > MAX_GRAMS) return null
|
||||
|
||||
return WeightReading(value, unit, stable)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Generic fallback parser
|
||||
// Tries common frame layouts used by many BLE kitchen scales.
|
||||
// -------------------------------------------------------------------------
|
||||
private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
if (data.size < 3) {
|
||||
debug?.invoke("generic: skip short packet (" + data.size + "B)")
|
||||
return null
|
||||
}
|
||||
|
||||
data class C(val pos: Int, val be: Boolean, val div: Float, val label: String)
|
||||
|
||||
val candidates = listOf(
|
||||
// Direct grams (1g resolution)
|
||||
C(1, false, 1f, "pos1 LE g"),
|
||||
C(1, true, 1f, "pos1 BE g"),
|
||||
C(2, false, 1f, "pos2 LE g"),
|
||||
C(2, true, 1f, "pos2 BE g"),
|
||||
C(3, false, 1f, "pos3 LE g"),
|
||||
C(3, true, 1f, "pos3 BE g"),
|
||||
// 0.1g resolution (high-precision scales)
|
||||
C(1, false, 10f, "pos1 LE 0.1g"),
|
||||
C(1, true, 10f, "pos1 BE 0.1g"),
|
||||
C(2, false, 10f, "pos2 LE 0.1g"),
|
||||
C(2, true, 10f, "pos2 BE 0.1g"),
|
||||
C(3, false, 10f, "pos3 LE 0.1g"),
|
||||
C(3, true, 10f, "pos3 BE 0.1g"),
|
||||
// 0.5g resolution
|
||||
C(1, false, 2f, "pos1 LE 0.5g"),
|
||||
C(1, true, 2f, "pos1 BE 0.5g"),
|
||||
// Raw = centgrams (raw*10 = g)
|
||||
C(1, false, 0.1f, "pos1 LE cg"),
|
||||
C(1, true, 0.1f, "pos1 BE cg"),
|
||||
C(3, false, 0.1f, "pos3 LE cg"),
|
||||
C(3, true, 0.1f, "pos3 BE cg"),
|
||||
)
|
||||
|
||||
for (c in candidates) {
|
||||
if (c.pos + 1 >= data.size) continue
|
||||
val raw = if (c.be) u16be(data, c.pos) else u16le(data, c.pos)
|
||||
if (raw == 0) continue
|
||||
val g = raw / c.div
|
||||
if (g in MIN_GRAMS..MAX_GRAMS) {
|
||||
debug?.invoke("generic [${c.label}]: raw=$raw -> ${g}g (unstable)")
|
||||
return WeightReading(g, "g", stable = false)
|
||||
}
|
||||
}
|
||||
debug?.invoke("generic: no valid candidate in " + data.size + " bytes")
|
||||
return null
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
private fun u16le(b: ByteArray, off: Int): Int =
|
||||
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
private fun u16be(b: ByteArray, off: Int): Int =
|
||||
((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#6C63FF"
|
||||
android:pathData="M0,0h108v108H0z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,30L54,70 M40,38L68,38 M36,54L44,38 M64,54L72,38 M36,54C36,56 44,56 44,54 M64,54C64,56 72,56 72,54" />
|
||||
</vector>
|
||||
@@ -0,0 +1,340 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="#F3F4F6">
|
||||
|
||||
<!-- ── Update banner (shown at the TOP when a new version is available) ─ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/updateBanner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="#1e293b"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:gravity="center_vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvUpdateMessage"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textColor="#fbbf24"
|
||||
android:textSize="13sp"
|
||||
android:text="" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnInstallUpdate"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="⬇ Scarica"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#1e293b"
|
||||
android:backgroundTint="#fbbf24"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDismissUpdate"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="✕"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#94a3b8"
|
||||
android:backgroundTint="@android:color/transparent"
|
||||
style="@style/Widget.MaterialComponents.Button.TextButton" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────────── -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⚖️ EverShelf Scale Gateway"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1E293B"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Connect your smart scale to EverShelf via Bluetooth"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#64748B" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_version"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="v?.?.?"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#94A3B8"
|
||||
android:fontFamily="monospace"
|
||||
android:gravity="end" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ── Gateway URL card ───────────────────────────────────────────── -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
app:cardBackgroundColor="#EFF6FF">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🌐 Gateway URL (paste into EverShelf)"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#64748B"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_gateway_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="ws://…:8765"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1D4ED8"
|
||||
android:fontFamily="monospace"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_gateway_url_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Settings → Smart Scale"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#94A3B8"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_copy_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📋 Copy URL"
|
||||
android:backgroundTint="#1D4ED8"
|
||||
android:textColor="#FFFFFF"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- ── Gateway status ────────────────────────────────────────────── -->
|
||||
<TextView
|
||||
android:id="@+id/tv_gateway_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⏳ Starting gateway…"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#64748B"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_client_count"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="12sp"
|
||||
android:textColor="#059669"
|
||||
android:paddingBottom="12dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- ── Scale connection card ──────────────────────────────────────── -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/card_connection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
app:cardBackgroundColor="@android:color/darker_gray">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_scale_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⚡ Ready — scan for a scale"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#FFFFFF"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_weight"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="— — —"
|
||||
android:textSize="46sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#FFFFFF"
|
||||
android:gravity="center"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_weight_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="12sp"
|
||||
android:textColor="#E2E8F0"
|
||||
android:gravity="center"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_battery"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="12sp"
|
||||
android:textColor="#E2E8F0"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_disconnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔌 Disconnect scale"
|
||||
android:backgroundTint="#EF4444"
|
||||
android:textColor="#FFFFFF"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="8dp"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- ── Scan controls ──────────────────────────────────────────────── -->
|
||||
<Button
|
||||
android:id="@+id/btn_scan"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔍 Scan for Bluetooth Scales"
|
||||
android:backgroundTint="#7C3AED"
|
||||
android:textColor="#FFFFFF"
|
||||
android:layout_marginBottom="8dp"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_debug"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="\uD83D\uDC1B Debug"
|
||||
android:backgroundTint="#374151"
|
||||
android:textColor="#FFFFFF"
|
||||
android:layout_marginEnd="4dp"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_copy_log"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="\uD83D\uDCCB"
|
||||
android:backgroundTint="#374151"
|
||||
android:textColor="#FFFFFF"
|
||||
android:minWidth="48dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_share_log"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="\uD83D\uDCE4"
|
||||
android:backgroundTint="#374151"
|
||||
android:textColor="#FFFFFF"
|
||||
android:minWidth="48dp"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/sv_debug_log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="220dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="#111827"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_debug_log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="10sp"
|
||||
android:fontFamily="monospace"
|
||||
android:textColor="#4ADE80"
|
||||
android:padding="8dp" />
|
||||
</ScrollView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_scan_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Press to scan for nearby BLE scales.\nMake sure the scale is turned on."
|
||||
android:textSize="12sp"
|
||||
android:textColor="#64748B"
|
||||
android:paddingBottom="12dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- ── Device list ─────────────────────────────────────────────────── -->
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_devices"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:nestedScrollingEnabled="false" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:cardCornerRadius="10dp"
|
||||
app:cardElevation="1dp"
|
||||
app:cardBackgroundColor="#FFFFFF">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="14dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:text="⚖️"
|
||||
android:textSize="20sp"
|
||||
android:gravity="center"
|
||||
android:layout_marginEnd="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_device_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1E293B" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_device_addr"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="11sp"
|
||||
android:fontFamily="monospace"
|
||||
android:textColor="#94A3B8" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_device_rssi"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#64748B" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 122 B |
|
After Width: | Height: | Size: 122 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 546 B |
|
After Width: | Height: | Size: 546 B |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="accent">#7C3AED</color>
|
||||
<color name="green">#059669</color>
|
||||
<color name="red">#EF4444</color>
|
||||
<color name="blue">#1D4ED8</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EverShelf Scale Gateway</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- App-private external dir: no storage permission needed -->
|
||||
<external-files-path name="apk_downloads" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,5 @@
|
||||
// Top-level build file
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
}
|
||||