Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Page } from "@/components/Page";
import { ControlGrid } from "@/control/ControlGrid";
import { ControlCard } from "@/control/ControlCard";
import { Label } from "@/control/Label";
import { SelectionGroup } from "@/control/SelectionGroup";
import { Button } from "@/components/ui/button";
import { EditValue } from "@/control/EditValue";
import { Badge } from "@/components/ui/badge";
import { useMinimalBottleSorter } from "./useMinimalBottleSorter";
import React from "react";

export function MinimalBottleSorterControlPage() {
const {
state,
liveValues,
setStepperSpeed,
setStepperDirection,
setStepperEnabled,
pulseOutput,
} = useMinimalBottleSorter();

const safeState = state ?? {
stepper_enabled: false,
stepper_speed: 0,
stepper_direction: true,
outputs: [false, false, false, false, false, false, false, false],
};
const safeLiveValues = liveValues ?? {
stepper_actual_speed: 0,
stepper_position: 0,
};

return (
<Page>
<ControlGrid columns={2}>
{/* Stepper Motor Control */}
<ControlCard title="Stepper Motor">
<div className="space-y-6">
{/* Enable/Disable */}
<Label label="Motor Enable">
<SelectionGroup<"Enabled" | "Disabled">
value={safeState.stepper_enabled ? "Enabled" : "Disabled"}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Disabled: {
children: "Disabled",
icon: "lu:CirclePause",
isActiveClassName: "bg-red-600",
className: "h-full",
},
Enabled: {
children: "Enabled",
icon: "lu:CirclePlay",
isActiveClassName: "bg-green-600",
className: "h-full",
},
}}
onChange={(value) => setStepperEnabled(value === "Enabled")}
/>
</Label>

{/* Direction */}
<Label label="Direction">
<SelectionGroup<"Forward" | "Backward">
value={safeState.stepper_direction ? "Forward" : "Backward"}
orientation="vertical"
className="grid h-full grid-cols-2 gap-2"
options={{
Forward: {
children: "Forward",
icon: "lu:ArrowRight",
isActiveClassName: "bg-blue-600",
className: "h-full",
},
Backward: {
children: "Backward",
icon: "lu:ArrowLeft",
isActiveClassName: "bg-blue-600",
className: "h-full",
},
}}
onChange={(value) => setStepperDirection(value === "Forward")}
/>
</Label>

{/* Speed Control */}
<Label label="Speed (mm/s)">
<EditValue
title="Speed"
value={safeState.stepper_speed}
onChange={setStepperSpeed}
renderValue={(v) => v.toFixed(1)}
min={0}
max={100}
step={0.1}
/>
</Label>

{/* Live Values */}
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-400">Actual Speed:</span>
<Badge className="bg-blue-600">
{safeLiveValues.stepper_actual_speed.toFixed(1)} steps/s
</Badge>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-400">Position:</span>
<Badge className="bg-purple-600">
{safeLiveValues.stepper_position.toLocaleString()} steps
</Badge>
</div>
</div>
</div>
</ControlCard>

{/* Digital Output Pulses */}
<ControlCard title="Digital Output Pulses">
<div className="grid grid-cols-2 gap-4">
{safeState.outputs.map((output, index) => (
<div key={index} className="space-y-2">
<Label label={`Output ${index + 1}`}>
<Button
onClick={() => pulseOutput(index, 100)}
className={`w-full ${output ? "bg-green-600" : "bg-gray-600"}`}
disabled={output}
>
{output ? "Active" : "Pulse"}
</Button>
</Label>
</div>
))}
</div>
<div className="mt-4 text-sm text-gray-400">
Click to pulse output for 100ms
</div>
</ControlCard>
</ControlGrid>
</Page>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Topbar } from "@/components/Topbar";
import { bottleSorterSerialRoute } from "@/routes/routes";
import React from "react";

export function MinimalBottleSorterPage(): React.JSX.Element {
const { serial } = bottleSorterSerialRoute.useParams();

return (
<Topbar
pathname={`/_sidebar/machines/minimalbottlesorter/${serial}`}
items={[
{
link: "control",
activeLink: "control",
title: "Control",
icon: "lu:ToggleLeft",
},
]}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { StoreApi } from "zustand";
import { create } from "zustand";
import { z } from "zod";
import {
EventHandler,
eventSchema,
Event,
handleUnhandledEventError,
NamespaceId,
createNamespaceHookImplementation,
ThrottledStoreUpdater,
} from "@/client/socketioStore";
import { MachineIdentificationUnique } from "@/machines/types";

// ========== Event Schema ==========

export const stateEventDataSchema = z.object({
stepper_enabled: z.boolean(),
stepper_speed: z.number(),
stepper_direction: z.boolean(),
outputs: z.array(z.boolean()).length(8),
});

export const liveValuesEventDataSchema = z.object({
stepper_actual_speed: z.number(),
stepper_position: z.number(),
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backend uses i128 for stepper_position, but the frontend schema uses z.number() which represents a JavaScript number (max safe integer is 2^53-1). This type mismatch may cause precision loss or overflow issues if the stepper position exceeds JavaScript's safe integer range. Consider using z.bigint() or documenting the expected range of the stepper position.

Suggested change
stepper_position: z.number(),
stepper_position: z.bigint(),

Copilot uses AI. Check for mistakes.
});

export const stateEventSchema = eventSchema(stateEventDataSchema);
export const liveValuesEventSchema = eventSchema(liveValuesEventDataSchema);

export type StateEvent = z.infer<typeof stateEventDataSchema>;
export type LiveValuesEvent = z.infer<typeof liveValuesEventDataSchema>;

// ========== Store ==========
export type MinimalBottleSorterNamespaceStore = {
state: StateEvent | null;
liveValues: LiveValuesEvent | null;
};

export const createMinimalBottleSorterNamespaceStore =
(): StoreApi<MinimalBottleSorterNamespaceStore> =>
create<MinimalBottleSorterNamespaceStore>(() => ({
state: null,
liveValues: null,
}));

// ========== Message Handler ==========
export function minimalBottleSorterMessageHandler(
store: StoreApi<MinimalBottleSorterNamespaceStore>,
throttledUpdater: ThrottledStoreUpdater<MinimalBottleSorterNamespaceStore>,
): EventHandler {
return (event: Event<any>) => {
const updateStore = (
updater: (
state: MinimalBottleSorterNamespaceStore,
) => MinimalBottleSorterNamespaceStore,
) => throttledUpdater.updateWith(updater);

try {
if (event.name === "StateEvent") {
const parsed = stateEventSchema.parse(event);
updateStore((current) => ({ ...current, state: parsed.data }));
} else if (event.name === "LiveValuesEvent") {
const parsed = liveValuesEventSchema.parse(event);
updateStore((current) => ({ ...current, liveValues: parsed.data }));
} else {
handleUnhandledEventError(event.name);
}
} catch (error) {
console.error(`Error processing ${event.name}:`, error);
throw error;
}
};
}

// ========== Namespace Hook ==========
const useMinimalBottleSorterNamespaceImplementation =
createNamespaceHookImplementation<MinimalBottleSorterNamespaceStore>({
createStore: createMinimalBottleSorterNamespaceStore,
createEventHandler: minimalBottleSorterMessageHandler,
});

export function useMinimalBottleSorterNamespace(
machine_identification_unique: MachineIdentificationUnique,
): MinimalBottleSorterNamespaceStore {
const namespaceId: NamespaceId = {
type: "machine",
machine_identification_unique,
};

return useMinimalBottleSorterNamespaceImplementation(namespaceId);
}
126 changes: 126 additions & 0 deletions electron/src/machines/minimalbottlesorter/useMinimalBottleSorter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { toastError } from "@/components/Toast";
import { useStateOptimistic } from "@/lib/useStateOptimistic";
import { bottleSorterSerialRoute } from "@/routes/routes";
import { MachineIdentificationUnique } from "@/machines/types";
import {
useMinimalBottleSorterNamespace,
StateEvent,
LiveValuesEvent,
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import LiveValuesEvent.

Suggested change
LiveValuesEvent,

Copilot uses AI. Check for mistakes.
} from "./minimalBottleSorterNamespace";
import { useMachineMutate } from "@/client/useClient";
import { produce } from "immer";
import { useEffect, useMemo } from "react";
import { minimalBottleSorter } from "@/machines/properties";
import { z } from "zod";

export function useMinimalBottleSorter() {
const { serial: serialString } = bottleSorterSerialRoute.useParams();

// Memoize machine identification
const machineIdentification: MachineIdentificationUnique = useMemo(() => {
const serial = parseInt(serialString);

if (isNaN(serial)) {
toastError(
"Invalid Serial Number",
`"${serialString}" is not a valid serial number.`,
);

return {
machine_identification: { vendor: 0, machine: 0 },
serial: 0,
};
}

return {
machine_identification: minimalBottleSorter.machine_identification,
serial,
};
}, [serialString]);

// Namespace state from backend
const { state, liveValues } = useMinimalBottleSorterNamespace(
machineIdentification,
);

// Optimistic state
const stateOptimistic = useStateOptimistic<StateEvent>();

useEffect(() => {
if (state) stateOptimistic.setReal(state);
}, [state, stateOptimistic]);

// Generic mutation sender
const { request: sendMutation } = useMachineMutate(
z.object({
action: z.string(),
value: z.any(),
}),
);

const updateStateOptimistically = (
producer: (current: StateEvent) => void,
serverRequest?: () => void,
) => {
const currentState = stateOptimistic.value;
if (currentState)
stateOptimistic.setOptimistic(produce(currentState, producer));
serverRequest?.();
};

const setStepperSpeed = (speed: number) => {
updateStateOptimistically(
(current) => {
current.stepper_speed = speed;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetStepperSpeed", value: { speed } },
}),
);
};

const setStepperDirection = (forward: boolean) => {
updateStateOptimistically(
(current) => {
current.stepper_direction = forward;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetStepperDirection", value: { forward } },
}),
);
};

const setStepperEnabled = (enabled: boolean) => {
updateStateOptimistically(
(current) => {
current.stepper_enabled = enabled;
},
() =>
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "SetStepperEnabled", value: { enabled } },
}),
);
};

const pulseOutput = (index: number, duration_ms: number = 100) => {
// Don't use optimistic updates for pulses - they're brief and backend-controlled
sendMutation({
machine_identification_unique: machineIdentification,
data: { action: "PulseOutput", value: { index, duration_ms } },
});
};

return {
state: stateOptimistic.value,
liveValues,
setStepperSpeed,
setStepperDirection,
setStepperEnabled,
pulseOutput,
};
}
Loading
Loading