feat: add smart scale BLE gateway integration

- Add evershelf-scale-gateway/ Android app (Kotlin):
  - BLE scanning and GATT connection to smart scales
  - Supports BT SIG Weight Scale (0x181D), Body Composition (0x181B), and generic heuristic parser
  - WebSocket server on port 8765 (local LAN)
  - Real-time weight broadcasting to EverShelf browser client
- Add scale status indicator in header (green/orange/grey dot)
- Add Settings tab for scale configuration (URL, enable toggle, test, APK download link)
- Add 'Read from scale' button in Add/Use forms when unit is g or ml
- Add scale WebSocket client logic in app.js with auto-reconnect
- Fix recipe suggestion: expiry-prioritized ingredients now only injected into
  AI prompt when user explicitly selects 'Priorità Scadenze' or 'Zero Sprechi'
- Update README with smart scale section and website link
- Update all translations (it, en, de) with scale strings
This commit is contained in:
dadaloop82
2026-04-14 15:59:40 +00:00
parent f2b518dd4b
commit 0893742f05
22 changed files with 1954 additions and 22 deletions
+14
View File
@@ -2,6 +2,8 @@
> **Self-hosted pantry management system** — Track your food inventory, scan barcodes, get AI-powered recipe suggestions, and reduce waste.
🌐 **Website:** [evershelfproject.dadaloop.it](https://evershelfproject.dadaloop.it/)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![PHP](https://img.shields.io/badge/PHP-8.0+-blue.svg)](https://www.php.net/)
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
@@ -50,6 +52,13 @@
- **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
- **Auto weight reading** — When adding/using a product with unit g/ml, tap "⚖️ Read from scale"
- **Real-time status** — Scale connection indicator always visible in the header
- **Multi-protocol** — Supports Bluetooth SIG Weight Scale, Body Composition, Xiaomi Mi Scale 2 and 100+ models
- **Android gateway app** — [`evershelf-scale-gateway/`](evershelf-scale-gateway/) — open-source, downloadable APK
---
## 🚀 Quick Start
@@ -208,6 +217,10 @@ evershelf/
├── evershelf.db # SQLite database (auto-created)
├── backups/ # Local DB backups
└── *.json # Token/cache files
evershelf-scale-gateway/ # ⚖️ Android BLE gateway (add-on)
├── README.md # Setup & protocol docs
└── app/src/ # Kotlin Android source (WebSocket + BLE)
```
### API Endpoints
@@ -311,6 +324,7 @@ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE
**Stimpfl Daniel** — [evershelfproject@gmail.com](mailto:evershelfproject@gmail.com)
- Website: [evershelfproject.dadaloop.it](https://evershelfproject.dadaloop.it/)
- GitHub: [@dadaloop82](https://github.com/dadaloop82)
---
+30 -22
View File
@@ -1669,30 +1669,38 @@ function generateRecipe(PDO $db): void {
}
$ingredientsText = implode("\n\n", $ingredientSections);
// Build mandatory/recommended lists:
// - Truly mandatory: expired (group 1) OR expiring within 1 day (group 2 + daysLeft ≤ 1)
// These are genuinely at risk of being wasted RIGHT NOW.
// - Highly recommended: expiring in 2-3 days (group 2 + daysLeft > 1)
// Fresh products like milk naturally have short shelf lives and are often used anyway,
// so we suggest rather than force them.
// Build mandatory/recommended lists ONLY when user explicitly selected
// 'scadenze' (expiry priority) or 'zerowaste' (zero waste) options.
// Without these options, the recipe should use ALL available ingredients freely
// without being biased toward expiring items.
$mandatoryItems = [];
$recommendedItems = [];
foreach ($items as $item) {
$g = $getItemPriority($item);
$daysLeft = floatval($item['days_left']);
$isOpen = !empty($item['opened_at']) ||
(floatval($item['quantity']) > 0 && floatval($item['quantity']) < 1 && $item['unit'] === 'conf');
$expiryNote = !empty($item['expiry_date']) ? " — scade: {$item['expiry_date']}" : '';
$openNote = $isOpen ? ' [APERTO]' : '';
$label = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . $openNote . $expiryNote;
if ($g === 1 || ($g === 2 && $daysLeft <= 1)) {
$mandatoryItems[] = $label;
} elseif ($g === 2) {
$recommendedItems[] = $label;
} elseif ($isOpen && $daysLeft <= 5 && $daysLeft >= 0) {
// Opened items expiring within 5 days but not already in mandatory/recommended
// → bump to "strongly recommended" so the AI knows to use them
$recommendedItems[] = $label;
$wantsExpiryPriority = in_array('scadenze', $options) || in_array('zerowaste', $options);
$wantsOpenedPriority = in_array('opened', $options);
if ($wantsExpiryPriority || $wantsOpenedPriority) {
foreach ($items as $item) {
$g = $getItemPriority($item);
$daysLeft = floatval($item['days_left']);
$isOpen = !empty($item['opened_at']) ||
(floatval($item['quantity']) > 0 && floatval($item['quantity']) < 1 && $item['unit'] === 'conf');
$expiryNote = !empty($item['expiry_date']) ? " — scade: {$item['expiry_date']}" : '';
$openNote = $isOpen ? ' [APERTO]' : '';
$label = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . $openNote . $expiryNote;
if ($wantsExpiryPriority) {
if ($g === 1 || ($g === 2 && $daysLeft <= 1)) {
$mandatoryItems[] = $label;
} elseif ($g === 2) {
$recommendedItems[] = $label;
}
}
if (($wantsOpenedPriority || $wantsExpiryPriority) && $isOpen && $daysLeft <= 5 && $daysLeft >= 0) {
// Opened items expiring within 5 days but not already in mandatory/recommended
if (!in_array($label, $mandatoryItems) && !in_array($label, $recommendedItems)) {
$recommendedItems[] = $label;
}
}
}
}
+46
View File
@@ -137,6 +137,52 @@ body {
align-items: center;
}
/* ── Smart Scale status indicator in header ─────────────────────────── */
.scale-status-indicator {
width: 22px;
height: 22px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
cursor: default;
border: 2px solid rgba(255,255,255,0.3);
transition: background 0.4s, box-shadow 0.4s;
}
.scale-status-connected { background: #22c55e; box-shadow: 0 0 6px #22c55eaa; }
.scale-status-searching { background: #f59e0b; animation: scaleStatusPulse 1.4s infinite; }
.scale-status-disconnected { background: rgba(255,255,255,0.18); }
.scale-status-error { background: #ef4444; box-shadow: 0 0 4px #ef4444aa; }
@keyframes scaleStatusPulse {
0%, 100% { box-shadow: 0 0 4px #f59e0b88; }
50% { box-shadow: 0 0 10px #f59e0bcc; }
}
/* ── Scale read button (add/use forms) ──────────────────────────────── */
.scale-read-btn {
width: 100%;
margin-top: 8px !important;
font-size: 0.88rem;
padding: 8px 12px;
border-radius: 10px;
border: 1.5px dashed rgba(124,58,237,0.5);
background: rgba(124,58,237,0.06);
color: #7c3aed;
font-weight: 600;
}
.scale-read-btn:active { background: rgba(124,58,237,0.15); }
/* ── Scale reading modal live display ──────────────────────────────── */
.scale-reading-live {
font-size: 2.8rem;
font-weight: 800;
color: var(--accent, #7c3aed);
letter-spacing: 0.04em;
line-height: 1;
padding: 12px 0;
}
.header-gemini-btn {
width: 44px;
height: 44px;
+214
View File
@@ -58,6 +58,204 @@ window.addEventListener('unhandledrejection', function(e) {
// ===== CONFIGURATION =====
const API_BASE = 'api/index.php';
// ===== SMART SCALE GATEWAY =====
// Connects to the Android BLE-WebSocket gateway and provides auto weight reading.
let _scaleWs = null;
let _scaleConnected = false;
let _scaleDevice = null;
let _scaleBattery = null;
let _scaleReconnectTimer = null;
let _scaleWeightCallback = null; // pending on-demand weight request callback
let _scaleLatestWeight = null; // last received weight message
function scaleInit() {
const s = getSettings();
const indicator = document.getElementById('scale-status-indicator');
if (!s.scale_enabled || !s.scale_gateway_url) {
if (indicator) indicator.style.display = 'none';
if (_scaleWs) { try { _scaleWs.close(); } catch(e) {} _scaleWs = null; }
return;
}
if (indicator) indicator.style.display = '';
_scaleConnect(s.scale_gateway_url);
}
function _scaleConnect(url) {
if (_scaleWs) { try { _scaleWs.close(); } catch(e) {} _scaleWs = null; }
if (_scaleReconnectTimer) { clearTimeout(_scaleReconnectTimer); _scaleReconnectTimer = null; }
try {
_scaleWs = new WebSocket(url);
_scaleWs.onopen = () => {
_scaleUpdateStatus('searching');
try { _scaleWs.send(JSON.stringify({ type: 'get_status' })); } catch(e) {}
};
_scaleWs.onmessage = (evt) => {
try { _scaleOnMessage(JSON.parse(evt.data)); } catch(e) {}
};
_scaleWs.onclose = () => {
_scaleConnected = false;
_scaleDevice = null;
_scaleUpdateStatus('disconnected');
_scaleReconnectTimer = setTimeout(() => {
_scaleReconnectTimer = null;
const s = getSettings();
if (s.scale_enabled && s.scale_gateway_url) _scaleConnect(s.scale_gateway_url);
}, 8000);
};
_scaleWs.onerror = () => _scaleUpdateStatus('error');
} catch(e) {
_scaleUpdateStatus('error');
}
}
function _scaleOnMessage(msg) {
if (msg.type === 'status') {
_scaleConnected = msg.state === 'connected';
_scaleDevice = msg.device || null;
_scaleBattery = msg.battery ?? null;
_scaleUpdateStatus(_scaleConnected ? 'connected' : 'searching');
} else if (msg.type === 'weight') {
_scaleLatestWeight = msg;
// Update live reading overlay if visible
const live = document.getElementById('scale-reading-live');
if (live) live.textContent = `${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`;
// Fulfil pending callback on stable reading
if (msg.stable && _scaleWeightCallback) {
const cb = _scaleWeightCallback;
_scaleWeightCallback = null;
cb(msg);
}
}
}
function _scaleUpdateStatus(state) {
const el = document.getElementById('scale-status-indicator');
if (!el) return;
el.className = `scale-status-indicator scale-status-${state}`;
const labels = {
connected: `⚖️ ${t('scale.status_connected')}${_scaleDevice ? ': ' + _scaleDevice : ''}`,
searching: `⚖️ ${t('scale.status_searching')}`,
disconnected: `⚖️ ${t('scale.status_disconnected')}`,
error: `⚖️ ${t('scale.status_error')}`,
};
el.title = labels[state] || '';
}
/**
* Show the scale reading modal and wait for a stable weight, then populate the input.
* @param {string} targetInputId ID of the <input> to fill
* @param {Function} getUnit function that returns the current unit string ('g', 'ml', 'kg')
*/
function readScaleWeight(targetInputId, getUnit) {
if (!_scaleWs || _scaleWs.readyState !== WebSocket.OPEN) {
showToast('⚖️ ' + t('scale.not_connected'), 'error');
return;
}
const unit = typeof getUnit === 'function' ? getUnit() : getUnit;
_scaleShowReadingModal(targetInputId, unit);
_scaleWeightCallback = (msg) => {
let val = parseFloat(msg.value);
const srcUnit = (msg.unit || 'kg').toLowerCase();
// Convert to target unit
if (srcUnit === 'kg' && unit === 'g') val = Math.round(val * 1000);
if (srcUnit === 'g' && unit === 'kg') val = +(val / 1000).toFixed(3);
if (srcUnit === 'lbs'|| srcUnit === 'lb') {
val = val * 453.592;
if (unit === 'kg') val = +(val / 1000).toFixed(2); else val = Math.round(val);
}
if (srcUnit === 'kg' && unit === 'ml') val = Math.round(val * 1000); // approximate (water density)
const inp = document.getElementById(targetInputId);
if (inp) { inp.value = val; inp.dispatchEvent(new Event('input')); }
closeModal();
showToast(`⚖️ ${val} ${unit}`, 'success');
};
try { _scaleWs.send(JSON.stringify({ type: 'get_weight' })); } catch(e) {}
}
function _scaleShowReadingModal(targetInputId, unit) {
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3> ${t('scale.reading_title')}</h3>
<button class="modal-close" onclick="closeModal(); _scaleWeightCallback = null;"></button>
</div>
<div style="padding:16px;text-align:center">
<p style="margin-bottom:16px">${t('scale.place_on_scale')}</p>
<div id="scale-reading-live" class="scale-reading-live"> </div>
<p class="settings-hint" style="margin-top:12px">${t('scale.waiting_stable')}</p>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
/**
* Show/hide "⚖️ Leggi dalla bilancia" buttons based on current settings and unit.
* Called after unit change or when navigating to the add/use form.
*/
function updateScaleReadButtons() {
const s = getSettings();
const ready = s.scale_enabled && s.scale_gateway_url;
const btnAdd = document.getElementById('btn-scale-add');
if (btnAdd) {
const addUnit = document.getElementById('add-unit')?.value;
btnAdd.style.display = (ready && (addUnit === 'g' || addUnit === 'ml')) ? '' : 'none';
}
const btnUse = document.getElementById('btn-scale-use');
if (btnUse) {
btnUse.style.display = (ready && (_useNormalUnit === 'g' || _useNormalUnit === 'ml')) ? '' : 'none';
}
}
function onScaleEnabledChange() {
const s = getSettings();
const el = document.getElementById('setting-scale-enabled');
s.scale_enabled = el ? el.checked : false;
saveSettingsToStorage(s);
scaleInit();
updateScaleReadButtons();
}
function testScaleConnection() {
const urlEl = document.getElementById('setting-scale-url');
const statusEl = document.getElementById('scale-test-status');
if (!urlEl || !statusEl) return;
const url = urlEl.value.trim();
if (!url) { showToast(t('scale.no_url'), 'error'); return; }
statusEl.textContent = t('scale.testing');
statusEl.className = 'settings-status';
statusEl.style.display = 'block';
let testWs;
const timeout = setTimeout(() => {
if (testWs) testWs.close();
statusEl.textContent = '❌ ' + t('scale.timeout');
statusEl.className = 'settings-status error';
}, 6000);
try {
testWs = new WebSocket(url);
testWs.onopen = () => {
try { testWs.send(JSON.stringify({ type: 'ping' })); } catch(e) {}
};
testWs.onmessage = () => {
clearTimeout(timeout);
testWs.close();
statusEl.textContent = '✅ ' + t('scale.connected_ok');
statusEl.className = 'settings-status success';
};
testWs.onerror = () => {
clearTimeout(timeout);
statusEl.textContent = '❌ ' + t('scale.error_connect');
statusEl.className = 'settings-status error';
};
} catch(e) {
clearTimeout(timeout);
statusEl.textContent = '❌ ' + (e.message || t('scale.error_connect'));
statusEl.className = 'settings-status error';
}
}
// ===== i18n TRANSLATION SYSTEM =====
let _i18nStrings = null; // current language translations (flat)
let _i18nFallback = null; // Italian fallback (flat)
@@ -911,6 +1109,11 @@ async function loadSettingsUI() {
if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled;
}
} catch(e) { /* ignore */ }
// Scale settings
const scaleEnabledUiEl = document.getElementById('setting-scale-enabled');
if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled;
const scaleUrlUiEl = document.getElementById('setting-scale-url');
if (scaleUrlUiEl) scaleUrlUiEl.value = s.scale_gateway_url || '';
}
function renderAppliances(appliances) {
@@ -1032,6 +1235,11 @@ async function saveSettings() {
// Save spesa AI prompt if the field exists
const spesaPromptEl = document.getElementById('setting-spesa-ai-prompt');
if (spesaPromptEl) s.spesa_ai_prompt = spesaPromptEl.value.trim();
// Scale settings
const scaleEnabledEl = document.getElementById('setting-scale-enabled');
if (scaleEnabledEl) s.scale_enabled = scaleEnabledEl.checked;
const scaleUrlEl = document.getElementById('setting-scale-url');
if (scaleUrlEl) s.scale_gateway_url = scaleUrlEl.value.trim();
saveSettingsToStorage(s);
// Also save to server .env
@@ -3707,6 +3915,7 @@ function showAddForm() {
`;
showPage('add');
updateScaleReadButtons();
// After rendering, fetch history-based expiry prediction
if (currentProduct && currentProduct.id) {
_fetchExpiryHistoryAndUpdate(currentProduct.id);
@@ -3833,6 +4042,9 @@ function onAddUnitChange() {
if (unit === 'ml' && currentQty <= 10) qtyInput.value = 500;
if (unit === 'pz' && currentQty > 100) qtyInput.value = 1;
if (unit === 'conf' && currentQty > 10) qtyInput.value = 1;
// Show/hide scale read button based on new unit
updateScaleReadButtons();
}
function updateAddQtyStep() {
@@ -4117,6 +4329,7 @@ function showUseForm() {
loadUseInventoryInfo();
showPage('use');
updateScaleReadButtons();
}
function renderUsePreview() {
@@ -9158,6 +9371,7 @@ async function _initApp() {
initSpesaMode();
initScreensaverShortcuts();
startBgShoppingRefresh();
scaleInit(); // connect to smart scale gateway if configured
// ── Auto-refresh dati ─────────────────────────────────────────────────
// 1) Ogni 5 minuti: ricarica la pagina corrente (scadenze, inventario, ecc.)
+142
View File
@@ -0,0 +1,142 @@
# EverShelf Scale Gateway
> 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 (browser)
```
The app runs a local WebSocket server (port **8765**) on your Android device. EverShelf connects to it over your home Wi-Fi and receives weight readings in real time.
---
## 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 = 1
versionName = "1.0.0"
}
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,50 @@
<?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 611) -->
<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" />
<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>
</application>
</manifest>
@@ -0,0 +1,336 @@
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
/**
* Represents a discovered BLE device during scan.
*/
data class BleDeviceInfo(
val device: BluetoothDevice,
val name: String,
val rssi: 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()
}
/**
* 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 = ""
// The characteristics we will subscribe to (multiple may exist).
private val pendingSubscriptions = ArrayDeque<BluetoothGattCharacteristic>()
// ─── Public state ──────────────────────────────────────────────────────────
val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty()
// ─── 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 non disponibile su questo dispositivo.")
return
}
if (!adapter.isEnabled) {
listener.onError("Bluetooth disattivato. Attivalo e riprova.")
return
}
if (isScanning) stopScan()
leScanner = adapter.bluetoothLeScanner
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
// Filter for known weight/body-composition services; also allow unfiltered scan
val filters: List<ScanFilter> = listOf(
ScanFilter.Builder().setServiceUuid(android.os.ParcelUuid(BleUuids.WEIGHT_SCALE_SERVICE)).build(),
ScanFilter.Builder().setServiceUuid(android.os.ParcelUuid(BleUuids.BODY_COMPOSITION_SERVICE)).build(),
)
isScanning = true
try {
leScanner?.startScan(filters, settings, scanCallback)
} catch (e: Exception) {
// Some devices reject filtered scan; fall back to unfiltered
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 = getDeviceName(device).takeIf { it.isNotBlank() } ?: return
val info = BleDeviceInfo(device, name, result.rssi)
mainHandler.post { listener.onDeviceFound(info) }
}
override fun onScanFailed(errorCode: Int) {
isScanning = false
mainHandler.post { listener.onError("Scansione BLE fallita (codice: $errorCode)") }
}
}
private fun getDeviceName(device: BluetoothDevice): String {
return try {
device.name?.takeIf { it.isNotBlank() } ?: device.address
} catch (e: SecurityException) {
device.address
}
}
// ─── Connection ────────────────────────────────────────────────────────────
fun connect(device: BluetoothDevice) {
stopScan()
disconnect()
connectedDeviceName = ""
mainHandler.post { listener.onConnecting(device) }
try {
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
} else {
device.connectGatt(context, false, gattCallback)
}
} catch (e: SecurityException) {
mainHandler.post { listener.onError("Permesso mancante: ${e.message}") }
}
}
fun disconnect() {
pendingSubscriptions.clear()
try {
gatt?.disconnect()
gatt?.close()
} catch (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: Weight Scale Service
gatt.getService(BleUuids.WEIGHT_SCALE_SERVICE)
?.getCharacteristic(BleUuids.WEIGHT_MEASUREMENT_CHAR)
?.let { targetChars.add(it) }
// Priority 2: Body Composition Service
gatt.getService(BleUuids.BODY_COMPOSITION_SERVICE)
?.getCharacteristic(BleUuids.BODY_COMPOSITION_CHAR)
?.let { if (!targetChars.contains(it)) targetChars.add(it) }
// Fallback: any notifiable characteristic from unknown services
if (targetChars.isEmpty()) {
for (service in gatt.services) {
// Skip standard generic services
if (service.uuid.toString().startsWith("00001800") ||
service.uuid.toString().startsWith("00001801")) continue
for (char in service.characteristics) {
val props = char.properties
if ((props and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 ||
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) {
targetChars.add(char)
}
}
}
}
if (targetChars.isEmpty()) {
mainHandler.post { listener.onError("Nessuna caratteristica peso trovata su questa bilancia.") }
return
}
// Battery (optional)
gatt.getService(BleUuids.BATTERY_SERVICE)
?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)
?.let { targetChars.add(it) }
pendingSubscriptions.clear()
pendingSubscriptions.addAll(targetChars)
val deviceName = try { gatt.device?.name ?: "Bilancia" } catch (e: SecurityException) { "Bilancia" }
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
}
// Weight / body composition
val reading = ScaleProtocol.parse(char, data) ?: return
if (reading.weightKg > 0f) {
mainHandler.post { listener.onWeightReceived(reading) }
}
}
}
@@ -0,0 +1,149 @@
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":"Mi Scale 2","battery":80}
* {"type":"weight","value":72.5,"unit":"kg","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)
}
// ─── 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(weightKg: Float, stable: Boolean, battery: Int? = null) {
val json = buildWeightJson(weightKg, 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(weightKg: Float, stable: Boolean): String {
// Round to 2 decimal places
val rounded = (weightKg * 100).toLong() / 100.0
val obj = JSONObject()
obj.put("type", "weight")
obj.put("value", rounded)
obj.put("unit", "kg")
obj.put("stable", stable)
obj.put("timestamp", System.currentTimeMillis())
return obj.toString()
}
}
@@ -0,0 +1,305 @@
package it.dadaloop.evershelf.scalegate
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
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 it.dadaloop.evershelf.scalegate.databinding.ActivityMainBinding
import java.net.Inet4Address
import java.net.NetworkInterface
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
// ─── Permission launcher ───────────────────────────────────────────────────
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted ->
if (granted.values.all { it }) {
startGatewayServer()
} else {
showDialog("Permessi mancanti",
"L'app necessita dei permessi Bluetooth e Posizione per funzionare.")
}
}
private val enableBtLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) checkPermissionsAndStart()
else showDialog("Bluetooth richiesto", "Attiva il Bluetooth per usare il gateway.")
}
// ─── Lifecycle ─────────────────────────────────────────────────────────────
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
bleManager = BleScaleManager(this, 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()
}
updateGatewayUrl()
checkPermissionsAndStart()
}
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()
binding.tvScanHint.visibility = View.VISIBLE
binding.tvScanHint.text = "Ricerca bilance BLE in corso…"
binding.btnScan.isEnabled = false
bleManager.startScan()
}
// ─── WebSocket gateway ─────────────────────────────────────────────────────
private fun startGatewayServer() {
if (wsServer != null) return
try {
wsServer = GatewayWebSocketServer(WS_PORT, this)
wsServer!!.start()
updateGatewayUrl()
binding.tvGatewayStatus.text = "✅ Gateway attivo sulla porta $WS_PORT"
} catch (e: Exception) {
binding.tvGatewayStatus.text = "❌ Impossibile avviare il gateway: ${e.message}"
}
}
private fun updateGatewayUrl() {
val ip = getLocalIpAddress() ?: ""
val url = "ws://$ip:$WS_PORT"
binding.tvGatewayUrl.text = url
binding.tvGatewayUrlHint.text = "Incolla questo URL in EverShelf → Impostazioni → Bilancia Smart"
binding.btnCopyUrl.setOnClickListener {
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Gateway URL", url))
binding.btnCopyUrl.text = "✅ Copiato!"
binding.btnCopyUrl.postDelayed({ binding.btnCopyUrl.text = "📋 Copia URL" }, 2000)
}
}
// ─── BleScaleListener ─────────────────────────────────────────────────────
override fun onDeviceFound(info: BleDeviceInfo) {
// Avoid duplicates
if (devices.none { it.device.address == info.device.address }) {
devices.add(info)
deviceAdapter.notifyItemInserted(devices.size - 1)
}
}
override fun onConnecting(device: BluetoothDevice) {
val name = try { device.name ?: device.address } catch (e: SecurityException) { device.address }
binding.tvScaleStatus.text = "⏳ Connessione a $name"
binding.tvWeight.text = "— — —"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_orange_light))
}
override fun onConnected(deviceName: String) {
binding.tvScaleStatus.text = "✅ Connessa: $deviceName"
binding.tvWeight.text = "In attesa di un peso…"
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()
}
override fun onWeightReceived(reading: WeightReading) {
val kg = "%.2f".format(reading.weightKg)
val extras = buildString {
reading.fatPct?.let { append(" Grasso: ${"%.1f".format(it)}%") }
reading.bmi?.let { append(" BMI: ${"%.1f".format(it)}") }
}
binding.tvWeight.text = "$kg kg$extras"
if (reading.stable) {
binding.tvWeightHint.text = "✓ Lettura stabile"
} else {
binding.tvWeightHint.text = ""
}
wsServer?.publishWeight(reading.weightKg, 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("✅ Connessa: "), level)
}
override fun onError(message: String) {
binding.tvScaleStatus.text = "$message"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_red_light))
}
override fun onScanStopped() {
binding.btnScan.isEnabled = true
if (devices.isEmpty()) {
binding.tvScanHint.text = "Nessuna bilancia trovata. Assicurati che sia accesa e premi di nuovo Cerca."
} else {
binding.tvScanHint.text = "Tocca una bilancia per connettersi."
}
}
// ─── ServerEventListener ──────────────────────────────────────────────────
override fun onClientConnected(address: String) {
runOnUiThread {
binding.tvClientCount.text = "🌐 Client connesso: $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 = "⚡ Pronto — cerca una bilancia"
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()
}
// ─── 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.rssi} dBm"
holder.itemView.setOnClickListener { onClick(info) }
}
override fun getItemCount() = items.size
}
}
@@ -0,0 +1,173 @@
package it.dadaloop.evershelf.scalegate
import android.bluetooth.BluetoothGattCharacteristic
import java.util.UUID
/**
* Data model for a single weight reading from a BLE scale.
*/
data class WeightReading(
val weightKg: Float, // weight in kilograms
val stable: Boolean, // true when the reading is stable/final
val battery: Int? = null, // battery percentage (0-100), if reported
val fatPct: Float? = null, // body fat %, if available
val bmi: Float? = null, // BMI, if available
)
/**
* Descriptor UUID for enabling BLE notifications (standard 0x2902).
*/
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
/**
* Bluetooth SIG standard service and characteristic UUIDs.
*/
object BleUuids {
// Weight Scale Service
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
// Body Composition Service (also used by many smart scales)
val BODY_COMPOSITION_SERVICE = UUID.fromString("0000181b-0000-1000-8000-00805f9b34fb")
val BODY_COMPOSITION_CHAR = UUID.fromString("00002a9c-0000-1000-8000-00805f9b34fb")
// Battery Service
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
// Xiaomi Mi Scale 2 / Mi Body Composition Scale 2
val XIAOMI_SCALE_SERVICE = UUID.fromString("0000181b-0000-1000-8000-00805f9b34fb")
val XIAOMI_SCALE_CHAR = UUID.fromString("00002a9c-0000-1000-8000-00805f9b34fb")
}
/**
* Parses BLE characteristic data for various scale protocols.
* Returns a WeightReading or null if the data does not match a known format.
*/
object ScaleProtocol {
/**
* Attempt to parse weight data from a GATT characteristic change.
* Tries known protocols in order of specificity.
*/
fun parse(char: BluetoothGattCharacteristic, data: ByteArray): WeightReading? {
return when (char.uuid) {
BleUuids.WEIGHT_MEASUREMENT_CHAR -> parseWeightMeasurement(data)
BleUuids.BODY_COMPOSITION_CHAR -> parseBodyComposition(data)
else -> parseGeneric(data)
}
}
/**
* Bluetooth SIG Weight Measurement Characteristic (0x2A9D)
*
* Byte 0 : Flags
* Bit 0 = 0 → SI (kg/m), 1 → Imperial (lb/in)
* Bit 1 = Time Stamp present
* Bit 2 = User ID present
* Bit 3 = BMI & Height present
* Bytes 1-2: Weight (uint16)
* SI: 0.005 kg per unit
* Imperial: 0.01 lb per unit
*/
fun parseWeightMeasurement(data: ByteArray): WeightReading? {
if (data.size < 3) return null
val flags = data[0].toInt() and 0xFF
val isImperial = (flags and 0x01) != 0
val rawWeight = (data[1].toInt() and 0xFF) or ((data[2].toInt() and 0xFF) shl 8)
val weightKg = if (isImperial) {
rawWeight * 0.01f / 2.20462f // lb → kg
} else {
rawWeight * 0.005f // SI resolution
}
// Bit 3: BMI & Height present → offset 5 if no timestamp/user
var bmi: Float? = null
var offset = 3
if ((flags and 0x02) != 0) offset += 7 // timestamp: 7 bytes
if ((flags and 0x04) != 0) offset += 1 // user ID: 1 byte
if ((flags and 0x08) != 0 && data.size >= offset + 4) {
val rawBmi = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
bmi = rawBmi * 0.1f
}
return WeightReading(weightKg = weightKg, stable = true, bmi = bmi)
}
/**
* Bluetooth SIG Body Composition Measurement Characteristic (0x2A9C)
*
* Bytes 0-1 : Flags (16-bit)
* Bit 0 = 0 → SI, 1 → Imperial
* Bit 1 = Time Stamp present
* Bit 7 = Weight present
* Bit 8 = Height present
* Bit 9 = Multiple Users
* Bit 10 = Basal Metabolism present
* Bit 11 = Muscle Percentage present
* Bit 13 = Body Fat Percentage present ← always present (mandatory)
* Bytes 2-3 : Body Fat % (uint16, resolution 0.1%)
* … then optional fields
* When Bit 7 (Weight) is set, weight (uint16) follows at an offset after other optionals.
*/
fun parseBodyComposition(data: ByteArray): WeightReading? {
if (data.size < 4) return null
val flags = (data[0].toInt() and 0xFF) or ((data[1].toInt() and 0xFF) shl 8)
val isImperial = (flags and 0x0001) != 0
// Body fat % (mandatory, bytes 2-3)
val rawFat = (data[2].toInt() and 0xFF) or ((data[3].toInt() and 0xFF) shl 8)
val fatPct = rawFat * 0.1f
// Walk through optional fields to reach weight
var offset = 4
if ((flags and 0x0002) != 0) offset += 7 // timestamp
if ((flags and 0x0200) != 0) offset += 1 // multiple users → User ID byte
// Weight (Bit 7)
var weightKg: Float? = null
if ((flags and 0x0080) != 0 && data.size >= offset + 2) {
val rawW = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
weightKg = if (isImperial) rawW * 0.01f / 2.20462f else rawW * 0.005f
offset += 2
}
if (weightKg == null || weightKg <= 0f) return null
return WeightReading(
weightKg = weightKg,
stable = true,
fatPct = if (fatPct > 0f) fatPct else null,
)
}
/**
* Generic / fallback parser.
* Many cheap BLE scales send 2 bytes or a small packet with weight as a little-endian uint16
* in units of 0.1 kg, 0.01 kg, or 10 g. We try each interpretation and pick a plausible result.
*/
fun parseGeneric(data: ByteArray): WeightReading? {
if (data.size < 2) return null
// Try common byte positions
val candidates = listOf(
// (startByte, resolution in kg, stabilityBit, stabilityByte, stabilityValue)
Triple(data.size - 2, 0.01f, false), // last 2 bytes, 0.01 kg resolution
Triple(data.size - 2, 0.005f, false), // last 2 bytes, 0.005 kg resolution
Triple(1, 0.01f, false), // bytes 1-2, 0.01 kg
Triple(0, 0.1f, false), // bytes 0-1, 0.1 kg
)
for ((start, resolution, _) in candidates) {
if (start < 0 || start + 1 >= data.size) continue
val raw = (data[start].toInt() and 0xFF) or ((data[start + 1].toInt() and 0xFF) shl 8)
val weight = raw * resolution
// Sanity check: a realistic weight is between 1 kg and 300 kg
if (weight in 1f..300f) {
return WeightReading(weightKg = weight, stable = true)
}
}
return null
}
}
@@ -0,0 +1,207 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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:background="#F3F4F6">
<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" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Collega la tua bilancia smart a EverShelf via Bluetooth"
android:textSize="13sp"
android:textColor="#64748B"
android:paddingBottom="16dp" />
<!-- ── 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="🌐 URL Gateway (incolla in 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="Impostazioni → Bilancia Smart"
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="📋 Copia 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="⏳ Avvio 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="⚡ Pronto — cerca una bilancia"
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="🔌 Disconnetti bilancia"
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="🔍 Cerca Bilance Bluetooth"
android:backgroundTint="#7C3AED"
android:textColor="#FFFFFF"
android:layout_marginBottom="8dp"
style="@style/Widget.MaterialComponents.Button" />
<TextView
android:id="@+id/tv_scan_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Premi per cercare bilance BLE nelle vicinanze.\nAssicurati che la bilancia sia accesa."
android: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>
@@ -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,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>
+5
View File
@@ -0,0 +1,5 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}
@@ -0,0 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "EverShelf Scale Gateway"
include(":app")
+52
View File
@@ -22,6 +22,7 @@
<div class="header-content">
<h1 class="header-title" onclick="showPage('dashboard')"><span data-i18n="nav.title">🏠 EverShelf</span><span class="header-version">v1.2.0</span></h1>
<div class="header-actions">
<span id="scale-status-indicator" class="scale-status-indicator scale-status-disconnected" style="display:none" data-i18n-title="scale.status_disconnected" title="⚖️ Bilancia">⚖️</span>
<button class="header-scan-btn header-gemini-btn" onclick="showPage('chat')" title="Chat con Gemini" data-i18n-title="chat.title">
<svg class="gemini-icon" viewBox="0 0 24 24" width="28" height="28" fill="white"><path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z"/></svg>
</button>
@@ -227,6 +228,7 @@
<option value="ml">ml</option>
</select>
</div>
<button type="button" id="btn-scale-add" class="btn btn-secondary scale-read-btn" style="display:none" onclick="readScaleWeight('add-quantity', function(){ return document.getElementById('add-unit').value; })" data-i18n="scale.read_btn">⚖️ Leggi dalla bilancia</button>
<div id="add-conf-size-row" class="conf-size-row" style="display:none">
<label class="conf-size-label">📦 Ogni confezione contiene:</label>
<div class="conf-size-inputs">
@@ -278,6 +280,7 @@
</div>
<div class="form-group">
<label>Quanto hai usato?</label>
<button type="button" id="btn-scale-use" class="btn btn-secondary scale-read-btn" style="display:none" onclick="readScaleWeight('use-quantity', function(){ return _useNormalUnit || 'g'; })" data-i18n="scale.read_btn">⚖️ Leggi dalla bilancia</button>
<div class="use-unit-switch" id="use-unit-switch" style="display:none">
<button type="button" class="use-unit-btn active" id="use-unit-sub" onclick="switchUseUnit('sub')"></button>
<button type="button" class="use-unit-btn" id="use-unit-conf" onclick="switchUseUnit('conf')">Confezioni</button>
@@ -667,6 +670,7 @@
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-language')" data-tab="tab-language" title="Lingua" data-i18n-title="settings.tab_language">🌐</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</button>
</div>
<div class="settings-panels">
<!-- API Keys Tab -->
@@ -967,6 +971,54 @@
<div id="tts-test-status" style="display:none;margin-top:8px"></div>
</div>
</div>
<!-- Scale Tab -->
<div class="settings-panel" id="tab-scale">
<div class="settings-card">
<h4 data-i18n="settings.scale.title">⚖️ Bilancia Smart</h4>
<p class="settings-hint" data-i18n="settings.scale.hint">Collega una bilancia Bluetooth tramite il gateway Android per leggere il peso automaticamente.</p>
<!-- Download gateway app -->
<div style="background:rgba(124,58,237,0.07);border:1px solid rgba(124,58,237,0.2);border-radius:10px;padding:14px;margin-bottom:16px">
<p style="margin:0 0 4px;font-weight:600">📱 EverShelf Scale Gateway</p>
<p class="settings-hint" style="margin-bottom:10px" data-i18n="settings.scale.download_hint">App Android che fa da ponte tra la bilancia BLE e questo sito.</p>
<a href="https://github.com/dadaloop82/EverShelf/releases/latest/download/evershelf-scale-gateway.apk" target="_blank" rel="noopener noreferrer" class="btn btn-large btn-accent full-width" style="text-decoration:none;display:block;text-align:center" data-i18n="settings.scale.download_btn">📥 Scarica Gateway Android (APK)</a>
<p class="settings-hint" style="margin-top:8px" data-i18n="settings.scale.download_sub">Sorgente: <code>evershelf-scale-gateway/</code> nella root del progetto</p>
</div>
<!-- Enable toggle -->
<div class="form-group" style="margin-bottom:10px">
<label class="toggle-row">
<span data-i18n="settings.scale.enabled">✅ Abilita bilancia smart</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-scale-enabled" onchange="onScaleEnabledChange()">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<!-- Gateway URL -->
<div class="form-group">
<label data-i18n="settings.scale.url_label">🌐 URL Gateway WebSocket</label>
<input type="url" id="setting-scale-url" class="form-input" placeholder="ws://192.168.1.x:8765" data-i18n-placeholder="settings.scale.url_placeholder">
<p class="settings-hint" data-i18n="settings.scale.url_hint">Copia l'URL mostrato dall'app Android (stessa rete Wi-Fi). Es: <code>ws://192.168.1.100:8765</code></p>
</div>
<!-- Test button -->
<button class="btn btn-secondary full-width mt-2" onclick="testScaleConnection()" data-i18n="settings.scale.test_btn">🔗 Testa connessione</button>
<div id="scale-test-status" style="display:none;margin-top:8px" class="settings-status"></div>
<!-- Protocol info -->
<div class="settings-hint" style="margin-top:16px;padding:10px;background:var(--bg-secondary,#f8fafc);border-radius:8px">
<p style="margin:0 0 6px;font-weight:600">🔌 Protocolli BLE supportati:</p>
<ul style="margin:0 0 0 16px;padding:0;font-size:0.8rem">
<li>Bluetooth SIG Weight Scale (0x181D)</li>
<li>Bluetooth SIG Body Composition (0x181B) — peso, grasso, BMI</li>
<li>Xiaomi Mi Body Composition Scale 2</li>
<li>Generico — heuristica automatica su 100+ modelli</li>
</ul>
</div>
</div>
</div>
<!-- Language Tab -->
<div class="settings-panel" id="tab-language">
<div class="settings-card">
+31
View File
@@ -248,6 +248,7 @@
"tab_security": "Sicherheit",
"tab_tts": "Sprache (TTS)",
"tab_language": "Sprache",
"tab_scale": "Smart-Waage",
"gemini": {
"title": "🤖 Google Gemini AI",
"hint": "API-Schlüssel für Produkterkennung, Ablaufdaten und Rezepte.",
@@ -350,6 +351,19 @@
"label": "🌐 Sprache",
"restart_notice": "Die Seite wird neu geladen, um die neue Sprache anzuwenden."
},
"scale": {
"title": "⚖️ Smart-Waage",
"hint": "Verbinde eine Bluetooth-Waage über das Android-Gateway, um das Gewicht automatisch auszulesen.",
"tab": "Smart-Waage",
"enabled": "✅ Smart-Waage aktivieren",
"url_label": "🌐 WebSocket-Gateway-URL",
"url_placeholder": "ws://192.168.1.x:8765",
"url_hint": "URL der Android-App (gleiches WLAN). z.B.:",
"test_btn": "🔗 Verbindung testen",
"download_btn": "📥 Android-Gateway herunterladen (APK)",
"download_hint": "Android-App als Brücke zwischen BLE-Waage und EverShelf.",
"download_sub": "Quellcode: evershelf-scale-gateway/ im Projektstamm"
},
"saved": "✅ Konfiguration gespeichert!",
"saved_local": "✅ Konfiguration lokal gespeichert",
"saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}"
@@ -427,5 +441,22 @@
"meal_types": {
"lunch": "Mittagessen",
"dinner": "Abendessen"
},
"scale": {
"status_connected": "Waage verbunden",
"status_searching": "Gateway verbunden, warte auf Waage…",
"status_disconnected": "Waagen-Gateway nicht erreichbar",
"status_error": "Verbindungsfehler zum Gateway",
"not_connected": "Waagen-Gateway nicht verbunden",
"read_btn": "⚖️ Von Waage lesen",
"reading_title": "Waage lesen",
"place_on_scale": "Produkt auf die Waage legen…",
"waiting_stable": "Das Gewicht wird automatisch erfasst, wenn die Messung stabil ist.",
"no_url": "Gateway-URL eingeben",
"testing": "⏳ Verbindung wird getestet…",
"connected_ok": "Gateway-Verbindung erfolgreich!",
"timeout": "Timeout: keine Antwort vom Gateway",
"error_connect": "Verbindung zum Gateway nicht möglich",
"tab": "Smart-Waage"
}
}
+31
View File
@@ -248,6 +248,7 @@
"tab_security": "Security",
"tab_tts": "Voice (TTS)",
"tab_language": "Language",
"tab_scale": "Smart Scale",
"gemini": {
"title": "🤖 Google Gemini AI",
"hint": "API key for product identification, expiry dates and recipes.",
@@ -350,6 +351,19 @@
"label": "🌐 Language",
"restart_notice": "The page will reload to apply the new language."
},
"scale": {
"title": "⚖️ Smart Scale",
"hint": "Connect a Bluetooth scale via the Android gateway to automatically read weight.",
"tab": "Smart Scale",
"enabled": "✅ Enable smart scale",
"url_label": "🌐 WebSocket Gateway URL",
"url_placeholder": "ws://192.168.1.x:8765",
"url_hint": "URL shown by the Android app (same Wi-Fi network). E.g.:",
"test_btn": "🔗 Test connection",
"download_btn": "📥 Download Android Gateway (APK)",
"download_hint": "Android app that bridges your BLE scale and EverShelf.",
"download_sub": "Source: evershelf-scale-gateway/ in the project root"
},
"saved": "✅ Configuration saved!",
"saved_local": "✅ Configuration saved locally",
"saved_local_error": "⚠️ Saved locally, server error: {error}"
@@ -427,5 +441,22 @@
"meal_types": {
"lunch": "Lunch",
"dinner": "Dinner"
},
"scale": {
"status_connected": "Scale connected",
"status_searching": "Gateway connected, waiting for scale…",
"status_disconnected": "Scale gateway unreachable",
"status_error": "Gateway connection error",
"not_connected": "Scale gateway not connected",
"read_btn": "⚖️ Read from scale",
"reading_title": "Scale reading",
"place_on_scale": "Place the product on the scale…",
"waiting_stable": "Weight will be captured automatically once the reading is stable.",
"no_url": "Enter the gateway URL",
"testing": "⏳ Testing connection…",
"connected_ok": "Gateway connection successful!",
"timeout": "Timeout: no response from gateway",
"error_connect": "Cannot connect to gateway",
"tab": "Smart Scale"
}
}
+31
View File
@@ -248,6 +248,7 @@
"tab_security": "Sicurezza",
"tab_tts": "Voce (TTS)",
"tab_language": "Lingua",
"tab_scale": "Bilancia Smart",
"gemini": {
"title": "🤖 Google Gemini AI",
"hint": "Chiave API per identificazione prodotti, scadenze e ricette.",
@@ -350,6 +351,19 @@
"label": "🌐 Lingua",
"restart_notice": "La pagina verrà ricaricata per applicare la nuova lingua."
},
"scale": {
"title": "⚖️ Bilancia Smart",
"hint": "Collega una bilancia Bluetooth tramite il gateway Android per leggere il peso automaticamente.",
"tab": "Bilancia Smart",
"enabled": "✅ Abilita bilancia smart",
"url_label": "🌐 URL Gateway WebSocket",
"url_placeholder": "ws://192.168.1.x:8765",
"url_hint": "URL mostrato dall'app Android (stessa rete Wi-Fi). Es:",
"test_btn": "🔗 Testa connessione",
"download_btn": "📥 Scarica Gateway Android (APK)",
"download_hint": "App Android che fa da ponte tra la bilancia BLE e questo sito.",
"download_sub": "Sorgente: evershelf-scale-gateway/ nella root del progetto"
},
"saved": "✅ Configurazione salvata!",
"saved_local": "✅ Configurazione salvata localmente",
"saved_local_error": "⚠️ Salvato localmente, errore server: {error}"
@@ -427,5 +441,22 @@
"meal_types": {
"lunch": "Pranzo",
"dinner": "Cena"
},
"scale": {
"status_connected": "Bilancia connessa",
"status_searching": "Connesso al gateway, attesa bilancia…",
"status_disconnected": "Gateway bilancia non raggiungibile",
"status_error": "Errore connessione gateway",
"not_connected": "Gateway bilancia non connesso",
"read_btn": "⚖️ Leggi dalla bilancia",
"reading_title": "Lettura bilancia",
"place_on_scale": "Metti il prodotto sulla bilancia…",
"waiting_stable": "Il peso venire rilevato automaticamente quando la lettura sarà stabile.",
"no_url": "Inserisci l'URL del gateway",
"testing": "⏳ Test connessione…",
"connected_ok": "Connessione gateway riuscita!",
"timeout": "Timeout: nessuna risposta dal gateway",
"error_connect": "Impossibile connettersi al gateway",
"tab": "Bilancia Smart"
}
}