Compare commits

...

2 Commits

Author SHA1 Message Date
dadaloop82 8a69e6d941 Fix kiosk LAN discovery and improve OTA update detection.
Discovery no longer aborts after 3s idle, probes priority hosts (.128, gateway) first, accepts ping API and normalizes HTTPS URLs. OTA compares versionCode from release notes; bump kiosk to 1.7.18.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 05:40:41 +00:00
dadaloop82 c5b0dbcf42 Fix inflated shopping price estimates and restore feature issues workflow.
Price each list line as one retail purchase instead of 14-day restock qty; convert €/kg AI prices to estimated piece weight; cap smart-shopping suggested conf/pz counts. Stop triage script from bulk-closing feature backlog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 05:34:56 +00:00
7 changed files with 189 additions and 74 deletions
+4 -2
View File
@@ -37,8 +37,10 @@ jobs:
id: version
run: |
VERSION=$(grep 'versionName' evershelf-kiosk/app/build.gradle.kts | grep -oP '"\K[^"]+')
VCODE=$(grep 'versionCode' evershelf-kiosk/app/build.gradle.kts | grep -oP '\d+')
echo "name=$VERSION" >> "$GITHUB_OUTPUT"
echo "Kiosk version: $VERSION"
echo "code=$VCODE" >> "$GITHUB_OUTPUT"
echo "Kiosk version: $VERSION (versionCode $VCODE)"
- name: Build debug APK
run: gradle assembleDebug --no-daemon
@@ -75,7 +77,7 @@ jobs:
sleep 3
gh release create kiosk-latest \
--title "EverShelf Kiosk Latest" \
--notes "Alias automatico → kiosk-${{ steps.version.outputs.name }}" \
--notes "Alias automatico → kiosk-${{ steps.version.outputs.name }} (versionCode ${{ steps.version.outputs.code }})" \
--prerelease \
artifacts/evershelf-kiosk.apk
+39 -24
View File
@@ -11198,12 +11198,20 @@ function smartShopping(PDO $db): void {
if ($buyCount > 0 && $totalUsed > $buyCount * 5 && $daysSinceFirst < 999) {
$need14 = ($buyCount / $daysSinceFirst) * 14;
}
$suggestedQty = (int) max(1, min(10, (int)($need14 + 0.3)));
// conf + package weight: express suggestion in g/ml, not raw conf count from mis-tracked grams.
if ($defQty > 0 && in_array(strtolower($pkgUnit), ['g', 'ml'], true)) {
$pkgs = (int) max(1, min(3, (int)($need14 + 0.3)));
$suggestedQty = $pkgs * (int) $defQty;
$suggestedUnit = strtolower($pkgUnit);
$suggestedApprox = $pkgs > 1;
} else {
$suggestedQty = (int) max(1, min(3, (int)($need14 + 0.3)));
$suggestedUnit = 'conf';
}
} elseif ($pkgUnit !== '' && $defQty > 0) {
// Real package info available → express in confezioni (definitive)
$pkgs = (int) max(1, min(10, (int)($need14 / $defQty + 0.3)));
$pkgs = (int) max(1, min(3, (int)($need14 / $defQty + 0.3)));
$suggestedQty = $pkgs;
$suggestedUnit = 'conf';
@@ -11212,7 +11220,7 @@ function smartShopping(PDO $db): void {
// use defQty as the minimum purchase unit and round to nearest multiple.
// This ensures we never suggest less than one "reference pack".
$pkgs = (int) max(1, (int)($need14 / $defQty + 0.3));
$pkgs = min(10, $pkgs);
$pkgs = min(3, $pkgs);
$suggestedQty = $pkgs * (int)$defQty;
$suggestedUnit = $unit;
$suggestedApprox = true; // always "almeno" — no confirmed pkg size
@@ -11234,8 +11242,8 @@ function smartShopping(PDO $db): void {
}
} elseif ($unit === 'pz') {
// No package info → raw pz count, approximate
$suggestedQty = (int) max(1, min(10, (int)($need14 + 0.3)));
// No package info → raw pz count, approximate (cap 5 — not 14-day bulk buy)
$suggestedQty = (int) max(1, min(5, (int)($need14 + 0.3)));
$suggestedUnit = 'pz';
$suggestedApprox = ($suggestedQty > 1);
}
@@ -11278,17 +11286,17 @@ function smartShopping(PDO $db): void {
if ($gap > 0) {
if ($unit === 'conf') {
if ($defQty > 0 && in_array(strtolower($pkgUnit), ['g', 'ml'])) {
$pkgs = (int)max(1, min(10, (int)ceil($gap / $defQty)));
$pkgs = (int)max(1, min(3, (int)ceil($gap / $defQty)));
$suggestedQty = $pkgs * (int)$defQty;
$suggestedUnit = strtolower($pkgUnit);
$suggestedApprox = true;
} else {
$suggestedQty = (int)max(1, min(10, (int)ceil($gap)));
$suggestedQty = (int)max(1, min(3, (int)ceil($gap)));
$suggestedUnit = 'conf';
$suggestedApprox = false;
}
} elseif ($unit === 'pz') {
$suggestedQty = (int)max(1, min(10, (int)ceil($gap)));
$suggestedQty = (int)max(1, min(5, (int)ceil($gap)));
$suggestedUnit = 'pz';
$suggestedApprox = $suggestedQty > 1;
} elseif ($unit === 'g' || $unit === 'ml') {
@@ -12856,22 +12864,16 @@ function _matchSmartShoppingItem(string $name, array $smartItems): ?array {
/**
* Resolve qty/unit/defQty for price estimation from smart-shopping suggestions.
* Each shopping-list line is priced as ONE typical retail purchase not 14-day restock stock.
*/
function _resolveShoppingPriceItem(string $name, array $smartItems): array {
$si = _matchSmartShoppingItem($name, $smartItems);
if ($si && !empty($si['suggested_qty']) && (float)$si['suggested_qty'] > 0) {
return [
'name' => $name,
'quantity' => (float)$si['suggested_qty'],
'unit' => trim($si['suggested_unit'] ?? $si['unit'] ?? 'conf'),
'default_quantity' => (float)($si['default_qty'] ?? 0),
'package_unit' => trim($si['package_unit'] ?? ''),
];
}
if ($si) {
$unit = trim($si['unit'] ?? 'conf');
$defQty = (float)($si['default_qty'] ?? 0);
$pkgUnit = trim($si['package_unit'] ?? '');
// Packaged goods (conf + weight/volume): one package at list price.
if ($unit === 'conf' && $defQty > 0 && $pkgUnit !== '') {
return [
'name' => $name,
@@ -12881,16 +12883,31 @@ function _resolveShoppingPriceItem(string $name, array $smartItems): array {
'package_unit' => $pkgUnit,
];
}
// Sold by piece: 23 items typical for a single shop trip.
if ($unit === 'pz') {
$gramsPerPiece = ($defQty >= 20) ? $defQty : 200.0;
return [
'name' => $name,
'quantity' => 2,
'unit' => 'pz',
'default_quantity' => $gramsPerPiece,
'package_unit' => 'g',
];
}
// Bulk g/ml with known reference pack: one pack, not multi-week stock.
if (($unit === 'g' || $unit === 'ml') && $defQty > 0) {
return [
'name' => $name,
'quantity' => $defQty,
'unit' => $unit,
'default_quantity' => $defQty,
'package_unit' => $pkgUnit,
];
}
}
return [
'name' => $name,
'quantity' => 1,
@@ -13288,13 +13305,11 @@ function _calcEstimatedTotal(float $pricePerUnit, string $priceUnitLabel, float
$weightKg = $qty;
}
if ($weightKg <= 0) {
// Two cases:
// A) defQty was 0 (no weight data at all) → "" is more honest than a fake price.
// B) defQty was 1-19 (suspicious: the value was stored as a piece count, not grams;
// the assignment was intentionally skipped by the defQty<20 guard above).
// In case B, fall back to ppu × qty so the badge shows something rather than €0.00.
if (in_array($unit, ['pz', 'conf']) && $defQty > 0) {
return round($pricePerUnit * max(1.0, $qty), 2);
// Piece/count units with €/kg AI price: estimate weight per piece (never €/kg × piece count).
if (in_array($unit, ['pz', 'conf'], true)) {
$gramsPerPiece = ($defQty >= 20) ? $defQty : 200.0;
$weightKg = max(1.0, $qty) * $gramsPerPiece / 1000.0;
return round($pricePerUnit * $weightKg, 2);
}
return null;
}
+20 -9
View File
@@ -12128,19 +12128,11 @@ async function syncShoppingPriceTotal(forceRefresh = false) {
function _buildPricePayload() {
return shoppingItems.map((item) => {
const smart = _matchBringToSmart(item.name, smartShoppingItems);
if (smart?.suggested_qty > 0) {
return {
name: item.name,
quantity: smart.suggested_qty,
unit: smart.suggested_unit || smart.unit || 'conf',
default_quantity: smart.default_qty || 0,
package_unit: smart.package_unit || '',
};
}
if (smart) {
const unit = smart.unit || 'conf';
const defQty = parseFloat(smart.default_qty) || 0;
const pkgUnit = smart.package_unit || '';
// One shopping-list line ≈ one retail purchase (not 14-day restock qty).
if (unit === 'conf' && defQty > 0 && pkgUnit) {
return {
name: item.name,
@@ -12150,6 +12142,25 @@ function _buildPricePayload() {
package_unit: pkgUnit,
};
}
if (unit === 'pz') {
const gramsPerPiece = defQty >= 20 ? defQty : 200;
return {
name: item.name,
quantity: 2,
unit: 'pz',
default_quantity: gramsPerPiece,
package_unit: 'g',
};
}
if ((unit === 'g' || unit === 'ml') && defQty > 0) {
return {
name: item.name,
quantity: defQty,
unit,
default_quantity: defQty,
package_unit: pkgUnit,
};
}
}
return { name: item.name, quantity: 1, unit: 'conf', default_quantity: 0, package_unit: '' };
});
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 35
versionCode = 18
versionName = "1.7.17"
versionCode = 19
versionName = "1.7.18"
}
signingConfigs {
@@ -667,10 +667,15 @@ class KioskActivity : AppCompatActivity() {
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
} catch (_: Exception) { "" }
val installedVc: Long = try {
val pi = packageManager.getPackageInfo(packageName, 0)
if (Build.VERSION.SDK_INT >= 28) pi.longVersionCode
else @Suppress("DEPRECATION") pi.versionCode.toLong()
} catch (_: Exception) { -1L }
// The kiosk-latest release uses a non-semver tag ("kiosk-latest").
// Extract the actual kiosk version from the release body text.
// Body format: "Alias automatico → kiosk-X.Y.Z" or just "kiosk-X.Y.Z".
// Fall back to stripping the tag prefix if body parsing fails.
// Body format: "Alias automatico → kiosk-X.Y.Z (versionCode N)".
val bodyText = json.optString("body", "")
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
val remoteKioskVersion = Regex("""kiosk-v?(\d+\.\d+(?:\.\d+)?)""")
@@ -678,6 +683,9 @@ class KioskActivity : AppCompatActivity() {
?.takeIf { it.isNotEmpty() }
?: norm(latestTag)
val remoteVc = Regex("""versionCode[=:\s(]+(\d+)""", RegexOption.IGNORE_CASE)
.find(bodyText)?.groupValues?.get(1)?.toLongOrNull() ?: -1L
// Compare semver: returns true if `remote` is strictly greater than `local`
fun semverNewer(remote: String, local: String): Boolean {
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
@@ -707,10 +715,11 @@ class KioskActivity : AppCompatActivity() {
}
if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL
// Only flag an update when the remote version is parseable as semver AND
// strictly greater than the installed version.
val kioskNeedsUpdate = currentKiosk.isNotEmpty() && isSemver &&
semverNewer(remoteKioskVersion, currentKiosk)
val kioskNeedsUpdate = when {
remoteVc > 0 && installedVc >= 0 -> remoteVc > installedVc
currentKiosk.isNotEmpty() && isSemver -> semverNewer(remoteKioskVersion, currentKiosk)
else -> false
}
val result = JSONObject()
.put("has_update", kioskNeedsUpdate)
@@ -697,6 +697,58 @@ class SetupActivity : AppCompatActivity() {
})
}
private fun normalizeDiscoveredBase(urlStr: String): String {
var base = urlStr.substringBefore("/api/")
if (base.endsWith(":443")) base = base.removeSuffix(":443")
if (base.endsWith(":80")) base = base.removeSuffix(":80")
return if (base.endsWith("/")) base else "$base/"
}
private fun probeEverShelfEndpoint(urlStr: String): String? {
return try {
val conn = openConn(urlStr) ?: return null
val code = conn.responseCode
if (code !in 200..399) {
conn.disconnect()
return null
}
val body = conn.inputStream.bufferedReader().readText()
conn.disconnect()
if (body.contains("gemini_key_set") || body.contains("\"success\"") || body.contains("\"ok\"")) {
normalizeDiscoveredBase(urlStr)
} else null
} catch (_: Exception) {
null
}
}
private fun probeEverShelfHost(ip: String, port: Int): String? {
val reachable = try {
Socket().use { s -> s.connect(InetSocketAddress(ip, port), 800); true }
} catch (_: Exception) {
false
}
if (!reachable) return null
val scheme = if (port == 443 || port == 8443) "https" else "http"
val portInUrl = when {
scheme == "https" && port == 443 -> ""
scheme == "http" && port == 80 -> ""
else -> ":$port"
}
val paths = listOf(
"/dispensa/api/index.php?action=ping",
"/api/index.php?action=ping",
"/dispensa/api/index.php?action=get_settings",
"/api/index.php?action=get_settings",
"/evershelf/api/index.php?action=get_settings",
)
for (path in paths) {
probeEverShelfEndpoint("$scheme://$ip$portInUrl$path")?.let { return it }
}
return null
}
private fun openConn(urlStr: String): HttpURLConnection? {
return try {
val conn = URL(urlStr).openConnection()
@@ -772,9 +824,52 @@ class SetupActivity : AppCompatActivity() {
runOnUiThread { discoverStatus.text = "📡 $detectedLabel" }
val ports = listOf(443, 80, 8080, 8443)
// ── 1b. Fast path: likely hosts on Wi-Fi subnet (incl. .128) before full sweep ─
val priorityIps = linkedSetOf<String>()
try {
val ifaces = NetworkInterface.getNetworkInterfaces()
while (ifaces != null && ifaces.hasMoreElements()) {
val intf = ifaces.nextElement()
if (!intf.isUp || intf.isLoopback) continue
for (addr in intf.interfaceAddresses) {
val ip = addr.address
if (ip is java.net.Inet4Address && !ip.isLoopbackAddress) {
priorityIps.add(ip.hostAddress ?: continue)
}
}
}
} catch (_: Exception) {}
for (subnet in wifiSubnets.ifEmpty { subnets.take(1) }) {
for (last in listOf(1, 128, 100, 10, 50, 254)) {
priorityIps.add("$subnet.$last")
}
}
runOnUiThread { discoverStatus.text = "🔍 ${getString(R.string.setup_discovering_detail)}" }
for (ip in priorityIps) {
if (discoverCancelled.get()) break
for (port in ports) {
val hit = probeEverShelfHost(ip, port)
if (hit != null) {
runOnUiThread {
urlEdit.setText(hit)
discoverStatus.text = "${getString(R.string.setup_server_found)}: $hit"
discoverStatus.setTextColor(0xFF34d399.toInt())
showUrlStatus("${getString(R.string.setup_server_found)}", true)
btnDiscover.isEnabled = true
btnDiscover.text = getString(R.string.setup_discover_btn)
}
return@Thread
}
}
}
val paths = listOf(
"/api/index.php?action=get_settings",
"/dispensa/api/index.php?action=ping",
"/api/index.php?action=ping",
"/dispensa/api/index.php?action=get_settings",
"/api/index.php?action=get_settings",
"/evershelf/api/index.php?action=get_settings",
)
@@ -819,30 +914,24 @@ class SetupActivity : AppCompatActivity() {
// Full HTTP probe on reachable host
val scheme = if (port == 443 || port == 8443) "https" else "http"
val portInUrl = when {
scheme == "https" && port == 443 -> ""
scheme == "http" && port == 80 -> ""
else -> ":$port"
}
for (path in paths) {
if (discoverCancelled.get() || found.get()) break
val urlStr = "$scheme://$ip:$port$path"
try {
val conn = openConn(urlStr) ?: continue
val code = conn.responseCode
if (code in 200..399) {
val body = conn.inputStream.bufferedReader().readText()
conn.disconnect()
if (body.contains("gemini_key_set") || body.contains("\"success\"")) {
return@submit urlStr.substringBefore("/api/") + "/"
}
} else conn.disconnect()
} catch (_: Exception) {}
probeEverShelfEndpoint("$scheme://$ip$portInUrl$path")?.let { return@submit it }
}
null
}
}
// ── 3. Collect results as they complete (not in submission order) ────
// ── 3. Collect results until all tasks finish or a server is found ────
var result: String? = null
var collected = 0
while (collected < total && !discoverCancelled.get()) {
val future = cs.poll(3, TimeUnit.SECONDS) ?: break
while (collected < total && !discoverCancelled.get() && result == null) {
val future = cs.poll(500, TimeUnit.MILLISECONDS) ?: continue
collected++
val r = try { future.get() } catch (_: Exception) { null }
if (r != null && found.compareAndSet(false, true)) {
+1 -12
View File
@@ -135,17 +135,6 @@ foreach ($bugs as $num => $msg) {
closeIssue($token, $repo, $num, $dryRun);
}
// ── Feature / enhancement backlog (close with acknowledgment) ───────────────
$features = [122, 121, 120, 119, 116, 115, 114, 106, 105, 104, 103, 102, 101, 97, 93, 81, 80, 79, 69, 67, 65];
$featMsg = <<<'MD'
Grazie per la proposta — è nel **backlog** del progetto.
Chiudiamo questa issue per tenere il tracker focalizzato sui bug attivi; la funzionalità resta nel radar per release future. **Riapri pure** quando vuoi lavorarci o seguirne lo sviluppo.
MD;
foreach ($features as $num) {
commentIssue($token, $repo, $num, $featMsg, $dryRun);
closeIssue($token, $repo, $num, $dryRun);
}
// Feature/enhancement issues stay OPEN — do not bulk-close backlog items here.
echo "Done.\n";