Merge branch 'develop'
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
name: Build & Release Scale Gateway APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'evershelf-scale-gateway/**'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build APK
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Make gradlew executable
|
||||
run: chmod +x evershelf-scale-gateway/gradlew
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug --no-daemon
|
||||
working-directory: evershelf-scale-gateway
|
||||
|
||||
- name: Rename APK
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
cp evershelf-scale-gateway/app/build/outputs/apk/debug/app-debug.apk \
|
||||
artifacts/evershelf-scale-gateway.apk
|
||||
|
||||
- name: Get version name
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep 'versionName' evershelf-scale-gateway/app/build.gradle.kts \
|
||||
| grep -oP '"\K[^"]+')
|
||||
echo "name=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Delete existing latest release (if any)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release delete latest --yes || true
|
||||
|
||||
- name: Create GitHub Release and upload APK
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create latest \
|
||||
--title "EverShelf Scale Gateway v${{ steps.version.outputs.name }}" \
|
||||
--notes "Automated release of EverShelf Scale Gateway v${{ steps.version.outputs.name }}.
|
||||
|
||||
## Download
|
||||
Download the APK below and install it on your Android device (Android 7.0+).
|
||||
Make sure to allow installation from unknown sources in your device settings." \
|
||||
--latest \
|
||||
artifacts/evershelf-scale-gateway.apk
|
||||
@@ -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)
|
||||
[](https://www.php.net/)
|
||||
[](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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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 = 2
|
||||
versionName = "1.3.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 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" />
|
||||
|
||||
<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>
|
||||
+336
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+149
@@ -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()
|
||||
}
|
||||
}
|
||||
+305
@@ -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
|
||||
}
|
||||
}
|
||||
+173
@@ -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>
|
||||
@@ -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")
|
||||
+53
-1
@@ -20,8 +20,9 @@
|
||||
<!-- Top Header -->
|
||||
<header class="app-header">
|
||||
<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>
|
||||
<h1 class="header-title" onclick="showPage('dashboard')"><span data-i18n="nav.title">🏠 EverShelf</span><span class="header-version">v1.3.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">
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user