944 lines
26 KiB
Svelte
944 lines
26 KiB
Svelte
<script lang="ts">
|
|
import Button from '$lib/components/ui/button/button.svelte';
|
|
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
|
import { onMount, onDestroy } from 'svelte';
|
|
|
|
import * as adb from '$lib/core/adb/adb';
|
|
import { addNotification } from '$lib/core/stores/noti';
|
|
import { columns, type RecipeOverview } from '../../recipe/overview/columns';
|
|
import {
|
|
materialFromMachineQuery,
|
|
recipeFromMachine,
|
|
recipeFromMachineLoading,
|
|
recipeFromMachineQuery,
|
|
toppingListFromMachineQuery,
|
|
referenceFromPage,
|
|
toppingGroupFromMachineQuery
|
|
} from '$lib/core/stores/recipeStore';
|
|
import DataTable from '../../recipe/overview/data-table.svelte';
|
|
import { handleIncomingMessages } from '$lib/core/handlers/messageHandler';
|
|
import { auth as authStore } from '$lib/core/stores/auth';
|
|
import { machineInfoStore } from '$lib/core/stores/machineInfoStore';
|
|
import { get } from 'svelte/store';
|
|
import {
|
|
AdbDaemonWebUsbDevice,
|
|
AdbDaemonWebUsbDeviceManager
|
|
} from '@yume-chan/adb-daemon-webusb';
|
|
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
|
|
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
|
|
import { afterNavigate, goto } from '$app/navigation';
|
|
import { env } from '$env/dynamic/public';
|
|
import { adbWriter, isAdbWriterAvailable, sendToAndroid } from '$lib/core/stores/adbWriter';
|
|
import { AdbInstance } from '../../../state.svelte';
|
|
import {
|
|
setOnMenuSavedCallback,
|
|
clearOnMenuSavedCallback,
|
|
clearMenuSaveState
|
|
} from '$lib/core/stores/menuSaveStore';
|
|
|
|
const sourceDir = '/sdcard/coffeevending';
|
|
const stagedMenuStorageKey = 'brew.create-menu.drafts.v1';
|
|
const deletedStagedMenuStorageKey = `${stagedMenuStorageKey}.deleted`;
|
|
const stagedMenuAndroidPath = `${sourceDir}/cfg/supra_draft_menus.json`;
|
|
|
|
// fetched recipe
|
|
let devRecipe: any | undefined = $state();
|
|
|
|
// data to display
|
|
let data: { recipes: RecipeOverview[] } = $state({
|
|
recipes: []
|
|
});
|
|
|
|
let brew_status: string = $state('');
|
|
|
|
// refresh command
|
|
let refresh_counter: number = $state(0);
|
|
let stagedMenus: any[] = $state([]);
|
|
let brewConfirmOpen = $state(false);
|
|
let pendingBrewMenu: any | null = $state(null);
|
|
let recipeLoading = $state(false);
|
|
let recipeAutoLoadAttempted = $state(false);
|
|
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
|
|
let isAndroidSocketConnected = $derived(Boolean($adbWriter));
|
|
let isRecipeLoaded = $derived(Boolean(devRecipe));
|
|
|
|
async function pullTextWithRetry(path: string, timeoutMs = 15000, attempts = 2) {
|
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
const content = await adb.pull(path, timeoutMs);
|
|
if (content != undefined) return content;
|
|
if (attempt < attempts) {
|
|
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function startFetchRecipeFromMachine() {
|
|
if (recipeLoading) return;
|
|
|
|
let instance = adb.getAdbInstance();
|
|
// recipeFromMachineLoading.set(true);
|
|
referenceFromPage.set('brew');
|
|
console.log('check instance', instance);
|
|
if (instance) {
|
|
recipeLoading = true;
|
|
try {
|
|
console.log('instance passed!');
|
|
const recipePaths = [
|
|
`${sourceDir}/cfg/recipe_branch_dev.json`,
|
|
`${sourceDir}/coffeethai02.json`
|
|
];
|
|
|
|
for (const recipePath of recipePaths) {
|
|
const dev_recipe = await pullTextWithRetry(recipePath);
|
|
console.log('dev recipe pull result', {
|
|
recipePath,
|
|
loaded: dev_recipe != undefined,
|
|
size: dev_recipe?.length ?? 0
|
|
});
|
|
if (!dev_recipe || dev_recipe.trim().length == 0) continue;
|
|
|
|
try {
|
|
devRecipe = JSON.parse(dev_recipe);
|
|
buildOverviewForBrewing();
|
|
return;
|
|
} catch (error) {
|
|
console.error('failed to parse recipe json', recipePath, error);
|
|
addNotification(`ERROR:Invalid recipe JSON from ${recipePath}`);
|
|
}
|
|
}
|
|
|
|
addNotification('ERROR:Cannot fetch recipe from machine');
|
|
} finally {
|
|
recipeLoading = false;
|
|
}
|
|
} else {
|
|
addNotification('ERROR:Cannot connect to machine');
|
|
// recipeFromMachineLoading.set(false);
|
|
}
|
|
}
|
|
|
|
async function loadEssentialFiles() {
|
|
let instance = adb.getAdbInstance();
|
|
if (instance) {
|
|
// check country
|
|
let country = await adb.pull('/sdcard/coffeevending/country/short');
|
|
// check dev
|
|
let devMode = await adb.pull('/sdcard/coffeevending/CURR_TEST');
|
|
// check .bid
|
|
let boxid = await adb.pull('/sdcard/coffeevending/.bid');
|
|
|
|
machineInfoStore.set({
|
|
boxId: boxid,
|
|
versions: {
|
|
firmware: '',
|
|
brew: '',
|
|
xmlengine: '',
|
|
netcore: '',
|
|
devbox: ''
|
|
},
|
|
devMode: devMode?.includes('1') ?? false,
|
|
country: country ?? '',
|
|
status: '',
|
|
errors: []
|
|
});
|
|
|
|
handleIncomingMessages(
|
|
JSON.stringify({
|
|
type: 'chat',
|
|
payload: `${new Date().toLocaleTimeString()}: ${get(authStore)?.displayName} has connected to ${boxid}`
|
|
})
|
|
);
|
|
} else {
|
|
addNotification('ERROR:Failed to get machine info');
|
|
}
|
|
}
|
|
|
|
async function connectAdb() {
|
|
try {
|
|
if (adb.getAdbInstance()) {
|
|
if (!isAdbWriterAvailable()) {
|
|
await adb.reconnectAndroidServer();
|
|
}
|
|
await loadBrewDataFromConnectedAdb();
|
|
return;
|
|
}
|
|
|
|
if (!('usb' in navigator)) {
|
|
throw new Error('WebUSB not supported, try using fallback method or different browser');
|
|
}
|
|
|
|
await adb.connnectViaWebUSB();
|
|
let instance = adb.getAdbInstance();
|
|
|
|
if (instance) {
|
|
await loadBrewDataFromConnectedAdb();
|
|
}
|
|
} catch (e: any) {
|
|
addNotification(`ERROR:${e}`);
|
|
}
|
|
}
|
|
|
|
async function loadBrewDataFromConnectedAdb() {
|
|
await startFetchRecipeFromMachine();
|
|
await loadEssentialFiles();
|
|
await loadStagedMenusFromAndroid();
|
|
}
|
|
|
|
async function tryAutoConnect() {
|
|
try {
|
|
if (adb.getAdbInstance()) {
|
|
if (!isAdbWriterAvailable()) {
|
|
await adb.reconnectAndroidServer();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (!('usb' in navigator) || !AdbDaemonWebUsbDeviceManager.BROWSER) {
|
|
throw new Error('WebUSB not supported, try using fallback method or different browser');
|
|
}
|
|
|
|
const devices = await AdbDaemonWebUsbDeviceManager.BROWSER.getDevices();
|
|
if (!devices || devices.length == 0) {
|
|
throw new Error('No device found');
|
|
}
|
|
|
|
if (devices.length > 1) {
|
|
throw new Error('Too many connected devices');
|
|
}
|
|
|
|
const device = devices[0];
|
|
const credStore = new AdbWebCredentialStore();
|
|
|
|
try {
|
|
await adb.connectDeviceByCred(device, credStore);
|
|
return true;
|
|
} catch (e: any) {
|
|
if (e.message === 'CREDENTIAL_EXPIRED') {
|
|
try {
|
|
await deviceCredentialManager.clearAllCredentials();
|
|
} catch (ignored) {}
|
|
}
|
|
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
|
|
addNotification(
|
|
'ERR:Device is already in use by another program, please close the program and try again'
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
console.error('error on auto connect brew page', e);
|
|
addNotification('ERROR:Failed to auto connect, please try again');
|
|
}
|
|
}
|
|
|
|
async function ensureAndroidSocket() {
|
|
if (isAdbWriterAvailable()) return true;
|
|
|
|
if (!adb.getAdbInstance()) {
|
|
addNotification('ERR:ADB is not connected');
|
|
return false;
|
|
}
|
|
|
|
await adb.reconnectAndroidServer();
|
|
|
|
if (!isAdbWriterAvailable()) {
|
|
addNotification('ERR:Android socket is not connected');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
afterNavigate(async () => {
|
|
console.log('after navigate brew');
|
|
await startFetchRecipeFromMachine();
|
|
});
|
|
|
|
function getMenuStatus(ms: number): RecipeOverview['status'] {
|
|
switch (ms) {
|
|
case 0:
|
|
return 'ready';
|
|
case 2:
|
|
return 'obsolete';
|
|
case 11:
|
|
return 'pending/online';
|
|
case 12:
|
|
return 'pending/offline';
|
|
default:
|
|
return 'drafted';
|
|
}
|
|
}
|
|
|
|
function getMenuCategory(pd: string): string {
|
|
// [country_code]-[category_code]-[drink_Type]-[id]
|
|
let pd_spl = pd.split('-');
|
|
let category = pd_spl[1] ?? '';
|
|
|
|
if (category) {
|
|
if (category.endsWith('1')) {
|
|
let result = 'coffee';
|
|
if (category === '01') {
|
|
result += ',v1';
|
|
} else {
|
|
result += ',v2+';
|
|
}
|
|
|
|
return result;
|
|
} else if (category.endsWith('2')) {
|
|
return 'tea';
|
|
} else if (category.endsWith('3')) {
|
|
return 'milk';
|
|
} else if (category.endsWith('4')) {
|
|
return 'whey';
|
|
} else if (category.endsWith('5')) {
|
|
return 'soda';
|
|
} else if (category == '99') {
|
|
return 'special';
|
|
}
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
function getDrinkType(pd: string): string {
|
|
// [country_code]-[category_code]-[drink_Type]-[id]
|
|
let pd_spl = pd.split('-');
|
|
let drink_type = pd_spl[2] ?? '';
|
|
|
|
if (drink_type) {
|
|
if (drink_type.endsWith('1')) {
|
|
return 'hot';
|
|
} else if (drink_type.endsWith('2')) {
|
|
return 'cold / iced';
|
|
} else if (drink_type.endsWith('3')) {
|
|
return 'smoothie / frappe';
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
// set material used in recipe to tags for using in filter
|
|
function getMainMaterialOfRecipe(rp: any): string {
|
|
let recipeList = rp['recipes'] ?? [];
|
|
let mat_lists = '';
|
|
|
|
for (let rpl of recipeList) {
|
|
let mat_id = rpl['materialPathId'];
|
|
let mat_in_use = rpl['isUse'];
|
|
|
|
if (mat_in_use && !mat_lists.includes(mat_id)) {
|
|
mat_lists += mat_id + ',';
|
|
}
|
|
}
|
|
|
|
mat_lists = mat_lists.substring(0, mat_lists.length - 1);
|
|
|
|
return mat_lists;
|
|
}
|
|
|
|
function buildTags(rp: any): string {
|
|
let result = '';
|
|
|
|
result += getMenuCategory(rp['productCode']);
|
|
|
|
let dt = getDrinkType(rp['productCode']);
|
|
let mats = getMainMaterialOfRecipe(rp);
|
|
|
|
if (dt !== '') result += ',' + dt;
|
|
if (mats !== '') result += ',' + mats;
|
|
|
|
return result;
|
|
}
|
|
|
|
function buildOverviewForBrewing() {
|
|
if (devRecipe) {
|
|
let recipe01_query: any = {};
|
|
recipeFromMachine.set(devRecipe);
|
|
data.recipes = [];
|
|
for (let rp of devRecipe['Recipe01']) {
|
|
data.recipes.push({
|
|
productCode: rp['productCode'] ?? '<not set>',
|
|
name: rp['name'] ? rp['name'] : (rp['otherName'] ?? '<not set>'),
|
|
description: rp['desciption']
|
|
? rp['desciption']
|
|
: (rp['otherDescription'] ?? '<not set>'),
|
|
tags: buildTags(rp),
|
|
status: getMenuStatus(rp['MenuStatus'])
|
|
});
|
|
|
|
recipe01_query[rp['productCode']] = rp;
|
|
|
|
if (rp['SubMenu'] && rp['SubMenu'].length > 0) {
|
|
for (let rps of rp['SubMenu']) {
|
|
data.recipes.push({
|
|
productCode: rps['productCode'] ?? '<not set>',
|
|
name: rps['name'] ? rps['name'] : (rps['otherName'] ?? '<not set>'),
|
|
description: rps['desciption']
|
|
? rps['desciption']
|
|
: (rps['otherDescription'] ?? '<not set>'),
|
|
tags: buildTags(rps),
|
|
status: getMenuStatus(rps['MenuStatus'])
|
|
});
|
|
|
|
recipe01_query[rps['productCode']] = rps;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const stagedMenu of stagedMenus) {
|
|
if (!recipe01_query[stagedMenu.productCode]) {
|
|
data.recipes.push({
|
|
productCode: stagedMenu.productCode ?? '<not set>',
|
|
name: stagedMenu.name ? stagedMenu.name : (stagedMenu.otherName ?? '<not set>'),
|
|
description: stagedMenu.Description
|
|
? stagedMenu.Description
|
|
: (stagedMenu.otherDescription ?? '<not set>'),
|
|
tags: buildTags(stagedMenu),
|
|
status: 'drafted'
|
|
});
|
|
}
|
|
}
|
|
|
|
let materialFromMachine = devRecipe['MaterialSetting'];
|
|
|
|
let currentQuery = get(recipeFromMachineQuery);
|
|
currentQuery = {
|
|
...currentQuery,
|
|
recipe: recipe01_query
|
|
};
|
|
|
|
let currentMaterialsQuery = materialFromMachine;
|
|
currentQuery = {
|
|
...currentQuery,
|
|
material: currentMaterialsQuery
|
|
};
|
|
|
|
recipeFromMachineQuery.set(currentQuery);
|
|
materialFromMachineQuery.set(currentMaterialsQuery);
|
|
|
|
toppingListFromMachineQuery.set(devRecipe['Topping']['ToppingList']);
|
|
toppingGroupFromMachineQuery.set(devRecipe['Topping']['ToppingGroup']);
|
|
}
|
|
}
|
|
|
|
function findRecipeByProductCode(productCode: string) {
|
|
if (!devRecipe?.Recipe01) return null;
|
|
for (const recipe of devRecipe.Recipe01) {
|
|
if (recipe?.productCode === productCode) return recipe;
|
|
for (const subMenu of recipe?.SubMenu ?? []) {
|
|
if (subMenu?.productCode === productCode) return subMenu;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isToppingSlotMaterial(materialId: number) {
|
|
return materialId > 8110 && materialId < 8131;
|
|
}
|
|
|
|
function getDeletedStagedMenuCodes() {
|
|
try {
|
|
const stored = localStorage.getItem(deletedStagedMenuStorageKey);
|
|
const parsed = stored ? JSON.parse(stored) : [];
|
|
return new Set(Array.isArray(parsed) ? parsed.map(String) : []);
|
|
} catch (error) {
|
|
return new Set<string>();
|
|
}
|
|
}
|
|
|
|
function persistDeletedStagedMenuCodes(codes: Set<string>) {
|
|
localStorage.setItem(deletedStagedMenuStorageKey, JSON.stringify([...codes]));
|
|
}
|
|
|
|
function markDeletedStagedMenu(productCode: string) {
|
|
const deletedCodes = getDeletedStagedMenuCodes();
|
|
deletedCodes.add(productCode);
|
|
persistDeletedStagedMenuCodes(deletedCodes);
|
|
}
|
|
|
|
function persistStagedMenus() {
|
|
localStorage.setItem(stagedMenuStorageKey, JSON.stringify(stagedMenus));
|
|
void persistStagedMenusToAndroid();
|
|
}
|
|
|
|
async function persistStagedMenusToAndroid() {
|
|
if (!adb.getAdbInstance()) return;
|
|
|
|
try {
|
|
await adb.push(
|
|
stagedMenuAndroidPath,
|
|
JSON.stringify(
|
|
{
|
|
version: 1,
|
|
updatedAt: new Date().toISOString(),
|
|
menus: stagedMenus
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
} catch (error) {
|
|
console.error('failed to persist staged menus to Android', error);
|
|
addNotification('WARN:Failed to save draft menus to Android');
|
|
}
|
|
}
|
|
|
|
async function loadStagedMenusFromAndroid() {
|
|
if (!adb.getAdbInstance()) return false;
|
|
|
|
try {
|
|
const content = await adb.pull(stagedMenuAndroidPath, 10000);
|
|
if (!content || content.trim().length === 0) {
|
|
if (stagedMenus.length > 0) {
|
|
await persistStagedMenusToAndroid();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const parsed = JSON.parse(content);
|
|
const menus = Array.isArray(parsed) ? parsed : parsed?.menus;
|
|
if (!Array.isArray(menus)) {
|
|
addNotification('WARN:Android draft menu file has invalid format');
|
|
return false;
|
|
}
|
|
|
|
const deletedCodes = getDeletedStagedMenuCodes();
|
|
const filteredMenus = menus.filter((menu) => !deletedCodes.has(String(menu?.productCode)));
|
|
|
|
stagedMenus = filteredMenus;
|
|
localStorage.setItem(stagedMenuStorageKey, JSON.stringify(stagedMenus));
|
|
if (filteredMenus.length !== menus.length) {
|
|
await persistStagedMenusToAndroid();
|
|
}
|
|
buildOverviewForBrewing();
|
|
return true;
|
|
} catch (error) {
|
|
console.error('failed to load staged menus from Android', error);
|
|
addNotification('WARN:Failed to load draft menus from Android');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function materialDisplayName(material: any) {
|
|
const thaiName = material?.materialName ?? '';
|
|
const englishName = material?.materialOtherName ?? '';
|
|
if (thaiName && englishName) return `${material.id} - ${thaiName} (${englishName})`;
|
|
return `${material?.id ?? '-'} - ${thaiName || englishName || 'Unnamed material'}`;
|
|
}
|
|
|
|
function getMaterial(materialPathId: number | null) {
|
|
if (materialPathId == null) return undefined;
|
|
return (devRecipe?.MaterialSetting ?? []).find(
|
|
(material: any) => Number(material?.id) === Number(materialPathId)
|
|
);
|
|
}
|
|
|
|
function toppingGroupDisplayName(group: any) {
|
|
const groupName = group?.otherName || group?.name || 'Unnamed group';
|
|
return `${group?.groupID ?? '-'} - ${groupName}`;
|
|
}
|
|
|
|
function toppingListDisplayName(topping: any) {
|
|
const toppingName = topping?.otherName || topping?.name || 'Unnamed topping';
|
|
return `${topping?.id ?? '-'} - ${toppingName}`;
|
|
}
|
|
|
|
function getAnyToppingGroup(groupID: number | null) {
|
|
if (groupID == null) return undefined;
|
|
return (devRecipe?.Topping?.ToppingGroup ?? []).find(
|
|
(group: any) => Number(group?.groupID) === Number(groupID)
|
|
);
|
|
}
|
|
|
|
function getAnyToppingList(toppingID: number | null) {
|
|
if (toppingID == null) return undefined;
|
|
return (devRecipe?.Topping?.ToppingList ?? []).find(
|
|
(topping: any) => Number(topping?.id) === Number(toppingID)
|
|
);
|
|
}
|
|
|
|
async function brewMenuOnAndroid(menu: any) {
|
|
if (!(await ensureAndroidSocket())) return;
|
|
|
|
await sendToAndroid({
|
|
type: 'brew_prep',
|
|
payload: {
|
|
start: new Date().toLocaleTimeString()
|
|
}
|
|
});
|
|
await sendToAndroid({
|
|
type: 'brew',
|
|
payload: {
|
|
start: new Date().toLocaleTimeString(),
|
|
target: '-',
|
|
data: menu
|
|
}
|
|
});
|
|
addNotification(`INFO:Brew request sent: ${menu.productCode}`);
|
|
}
|
|
|
|
function getRecipeStepValueSummary(step: any) {
|
|
const fields = [
|
|
['powderGram', 'Powder gram'],
|
|
['powderTime', 'Powder time'],
|
|
['syrupGram', 'Syrup gram'],
|
|
['syrupTime', 'Syrup time'],
|
|
['waterCold', 'Water cold'],
|
|
['waterYield', 'Water yield'],
|
|
['stirTime', 'Stir time']
|
|
];
|
|
|
|
return fields
|
|
.map(([key, label]) => ({ label, value: Number(step?.[key] ?? 0) }))
|
|
.filter((field) => Number.isFinite(field.value) && field.value !== 0);
|
|
}
|
|
|
|
function getPendingBrewMaterials() {
|
|
return (pendingBrewMenu?.recipes ?? [])
|
|
.filter((step: any) => {
|
|
const materialPathId = Number(step?.materialPathId);
|
|
return (
|
|
step?.isUse !== false &&
|
|
Number.isFinite(materialPathId) &&
|
|
!isToppingSlotMaterial(materialPathId)
|
|
);
|
|
})
|
|
.map((step: any, index: number) => {
|
|
const materialPathId = Number(step.materialPathId);
|
|
const material = getMaterial(materialPathId);
|
|
return {
|
|
index: index + 1,
|
|
materialPathId,
|
|
name: material ? materialDisplayName(material) : `${materialPathId} - Unknown material`,
|
|
values: getRecipeStepValueSummary(step)
|
|
};
|
|
});
|
|
}
|
|
|
|
function getPendingBrewToppings() {
|
|
return (pendingBrewMenu?.ToppingSet ?? [])
|
|
.map((toppingSet: any, index: number) => {
|
|
const groupID = Number(toppingSet?.groupID);
|
|
const defaultIDSelect = Number(toppingSet?.defaultIDSelect);
|
|
if (
|
|
toppingSet?.isUse === false ||
|
|
!Number.isFinite(groupID) ||
|
|
!Number.isFinite(defaultIDSelect) ||
|
|
defaultIDSelect <= 0
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const group = getAnyToppingGroup(groupID);
|
|
const topping = getAnyToppingList(defaultIDSelect);
|
|
const slotMaterial = getMaterial(8111 + index);
|
|
|
|
return {
|
|
slot: index + 1,
|
|
slotName:
|
|
slotMaterial?.materialOtherName ||
|
|
slotMaterial?.materialName ||
|
|
`Topping slot ${index + 1}`,
|
|
groupName: group ? toppingGroupDisplayName(group) : `${groupID} - Unknown group`,
|
|
toppingName: topping
|
|
? toppingListDisplayName(topping)
|
|
: `${defaultIDSelect} - Unknown topping`
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function openBrewConfirm(menu: any) {
|
|
pendingBrewMenu = menu;
|
|
brewConfirmOpen = true;
|
|
}
|
|
|
|
function promptBrewStagedMenu(menu: any) {
|
|
openBrewConfirm(menu);
|
|
}
|
|
|
|
async function confirmBrewNow() {
|
|
if (!pendingBrewMenu) return;
|
|
await brewMenuOnAndroid(pendingBrewMenu);
|
|
brewConfirmOpen = false;
|
|
pendingBrewMenu = null;
|
|
}
|
|
|
|
// Track menus pending save verification
|
|
let pendingSaveVerification = $state<Set<string>>(new Set());
|
|
|
|
async function verifyMenuSaved(productCode: string, attempt: number = 1): Promise<boolean> {
|
|
const maxAttempts = 3;
|
|
const delayMs = 2000; // 2 seconds between attempts
|
|
|
|
if (!adb.getAdbInstance() || recipeLoading) {
|
|
return false;
|
|
}
|
|
|
|
await startFetchRecipeFromMachine();
|
|
|
|
if (findRecipeByProductCode(productCode)) {
|
|
return true;
|
|
}
|
|
|
|
// Retry if not found and attempts remaining
|
|
if (attempt < maxAttempts) {
|
|
addNotification(`INFO:Retry ${attempt}/${maxAttempts} for ${productCode}...`);
|
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
return verifyMenuSaved(productCode, attempt + 1);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function handleMenuSaved(productCode: string) {
|
|
markDeletedStagedMenu(productCode);
|
|
stagedMenus = stagedMenus.filter((menu) => menu.productCode !== productCode);
|
|
persistStagedMenus();
|
|
clearMenuSaveState(productCode);
|
|
addNotification(`INFO:Menu saved: ${productCode}`);
|
|
buildOverviewForBrewing();
|
|
}
|
|
|
|
onMount(() => {
|
|
// Set up callback for when menu is saved to Android
|
|
setOnMenuSavedCallback(handleMenuSaved);
|
|
|
|
try {
|
|
const stored = localStorage.getItem(stagedMenuStorageKey);
|
|
stagedMenus = stored ? JSON.parse(stored) : [];
|
|
} catch (error) {
|
|
stagedMenus = [];
|
|
}
|
|
buildOverviewForBrewing();
|
|
if (adb.getAdbInstance()) {
|
|
void loadStagedMenusFromAndroid();
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
clearOnMenuSavedCallback();
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!isAdbConnected) {
|
|
recipeAutoLoadAttempted = false;
|
|
return;
|
|
}
|
|
|
|
if (isRecipeLoaded || recipeLoading || recipeAutoLoadAttempted) return;
|
|
|
|
recipeAutoLoadAttempted = true;
|
|
void loadBrewDataFromConnectedAdb();
|
|
});
|
|
|
|
$effect(() => {
|
|
const brewAppStatusInterval = setInterval(async () => {
|
|
// schedule status from .brew_web_status.log
|
|
let inst = adb.getAdbInstance();
|
|
if (inst && devRecipe && !recipeLoading) {
|
|
await adb.executeCmd(
|
|
'tail -n 1 /sdcard/coffeevending/.brew_web_status.log > /sdcard/coffeevending/.brew_web_status.latest.log'
|
|
);
|
|
|
|
const latestStatusPath = env.PUBLIC_BREW_WEB_LATEST_STATUS;
|
|
if (!latestStatusPath) return;
|
|
|
|
let brew_status_log = await adb.pull(latestStatusPath);
|
|
if (brew_status_log) {
|
|
let latest_log = brew_status_log;
|
|
|
|
if (brew_status !== latest_log) {
|
|
brew_status = latest_log;
|
|
|
|
// noti from machine
|
|
if (latest_log.includes('!!!')) {
|
|
let log = latest_log.split('!!!');
|
|
let noti_cfg = log[1];
|
|
|
|
if (noti_cfg.includes('=')) {
|
|
let noti_level = noti_cfg.split('=')[1];
|
|
|
|
let spl = log[0].split(':');
|
|
let pure_msg = spl[spl.length - 1];
|
|
|
|
// case special message
|
|
if (pure_msg.includes('starting retry process')) {
|
|
// is waiting/idle process
|
|
pure_msg = 'Wait for brewing';
|
|
}
|
|
|
|
addNotification(`${noti_level}:${pure_msg}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if (inst && !devRecipe) {
|
|
// try again
|
|
// await startFetchRecipeFromMachine();
|
|
}
|
|
}, 1000);
|
|
return () => {
|
|
clearInterval(brewAppStatusInterval);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div class="mx-8 flex">
|
|
<div class="w-full">
|
|
<div class="mb-4 flex items-center justify-between">
|
|
<div>
|
|
<h1 class="m-8 text-4xl font-bold">Brew</h1>
|
|
<!-- <p class="mx-8 my-0 text-muted-foreground">Brewing directly from web to machine</p>
|
|
<p class="mx-8 my-0 text-muted-foreground">
|
|
Note: refreshing page may cut connection with machine
|
|
</p> -->
|
|
</div>
|
|
<div class="mx-8 my-4 flex gap-2">
|
|
{#if !isAdbConnected}
|
|
<Button variant="default" onclick={() => connectAdb()}>Connect</Button>
|
|
{:else if !isRecipeLoaded}
|
|
<Button
|
|
variant="default"
|
|
onclick={() => loadBrewDataFromConnectedAdb()}
|
|
disabled={recipeLoading}
|
|
>
|
|
{recipeLoading ? 'Loading...' : 'Load Recipes'}
|
|
</Button>
|
|
{#if !isAndroidSocketConnected}
|
|
<Button variant="outline" onclick={() => adb.reconnectAndroidServer()}
|
|
>Reconnect Socket</Button
|
|
>
|
|
{/if}
|
|
{:else}
|
|
<Button variant="default" onclick={() => goto('/tools/create-menu?open=true')}
|
|
>+ Create Menu</Button
|
|
>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- <DashboardQuickAdb enableComponent={true} /> -->
|
|
</div>
|
|
|
|
<!-- search bar -->
|
|
|
|
{#key refresh_counter}
|
|
<div class="w-full">
|
|
{#if $recipeFromMachineLoading}
|
|
<div class="flex items-center justify-center">
|
|
<p class="mx-4">Please wait</p>
|
|
<Spinner />
|
|
</div>
|
|
{:else}
|
|
<DataTable data={data.recipes} refPage="brew" {columns} />
|
|
{/if}
|
|
</div>
|
|
{/key}
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog.Root bind:open={brewConfirmOpen}>
|
|
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
|
<Dialog.Header>
|
|
<Dialog.Title>Confirm Brew</Dialog.Title>
|
|
<Dialog.Description>
|
|
Check the material and topping list before sending this menu to Android.
|
|
</Dialog.Description>
|
|
</Dialog.Header>
|
|
|
|
{#if pendingBrewMenu}
|
|
<div class="grid gap-4 py-2">
|
|
<div class="rounded-md border bg-muted/20 p-4">
|
|
<div class="text-sm text-muted-foreground">Menu</div>
|
|
<div class="mt-1 text-lg font-semibold">
|
|
{pendingBrewMenu.name || pendingBrewMenu.otherName || pendingBrewMenu.productCode}
|
|
</div>
|
|
<div class="mt-1 font-mono text-sm text-muted-foreground">
|
|
{pendingBrewMenu.productCode}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-md border p-4">
|
|
<div class="mb-3 flex items-center justify-between gap-3">
|
|
<h3 class="text-base font-semibold">Materials</h3>
|
|
<span class="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">
|
|
{getPendingBrewMaterials().length} items
|
|
</span>
|
|
</div>
|
|
|
|
<div class="grid gap-2">
|
|
{#each getPendingBrewMaterials() as material}
|
|
<div class="rounded-md border bg-background/70 p-3">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<div class="text-xs text-muted-foreground">Step {material.index}</div>
|
|
<div class="font-medium">{material.name}</div>
|
|
</div>
|
|
<div class="font-mono text-xs text-muted-foreground">
|
|
{material.materialPathId}
|
|
</div>
|
|
</div>
|
|
|
|
{#if material.values.length > 0}
|
|
<div class="mt-3 flex flex-wrap gap-2">
|
|
{#each material.values as field}
|
|
<span
|
|
class="rounded-full bg-emerald-500 px-2.5 py-1 font-mono text-xs font-semibold text-white"
|
|
>
|
|
{field.label}: {field.value}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="mt-3 text-sm text-muted-foreground">No amount values set</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-md border p-4">
|
|
<div class="mb-3 flex items-center justify-between gap-3">
|
|
<h3 class="text-base font-semibold">Toppings</h3>
|
|
<span class="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">
|
|
{getPendingBrewToppings().length} items
|
|
</span>
|
|
</div>
|
|
|
|
{#if getPendingBrewToppings().length === 0}
|
|
<div class="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
|
No toppings selected.
|
|
</div>
|
|
{:else}
|
|
<div class="grid gap-2">
|
|
{#each getPendingBrewToppings() as topping}
|
|
<div class="rounded-md border bg-background/70 p-3">
|
|
<div class="text-xs text-muted-foreground">
|
|
Slot {topping.slot}: {topping.slotName}
|
|
</div>
|
|
<div class="mt-1 font-medium">{topping.toppingName}</div>
|
|
<div class="mt-1 text-sm text-muted-foreground">{topping.groupName}</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<Dialog.Footer>
|
|
<Button
|
|
variant="outline"
|
|
onclick={() => {
|
|
brewConfirmOpen = false;
|
|
pendingBrewMenu = null;
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onclick={confirmBrewNow} disabled={!pendingBrewMenu}>Confirm Brew</Button>
|
|
</Dialog.Footer>
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|