Working tree changes 2024-07-19 12:44
This commit is contained in:
commit
ff18d568e6
24
niimblue/.gitignore
vendored
Normal file
24
niimblue/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
4
niimblue/README.md
Normal file
4
niimblue/README.md
Normal file
@ -0,0 +1,4 @@
|
||||
# NiimBlue
|
||||
|
||||
|
||||
|
13
niimblue/index.html
Normal file
13
niimblue/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NiimBlue</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/index.ts"></script>
|
||||
</body>
|
||||
</html>
|
35
niimblue/package.json
Normal file
35
niimblue/package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "niimblue",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev-locallib": "yarn install-locallib && yarn dev-open",
|
||||
"dev": "vite --force",
|
||||
"dev-open": "vite --force --open",
|
||||
"build": "vite build",
|
||||
"build-rel": "vite build --base=./",
|
||||
"preview": "vite preview",
|
||||
"install-locallib": "yarn --cwd ../niimbluelib && yarn --cwd ../niimbluelib clean-build && yarn add file:../niimbluelib"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@mmote/niimbluelib": "file:../niimbluelib",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "5.3.3",
|
||||
"fabric": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.1",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/fabric": "^5.3.7",
|
||||
"@types/node": "^20.14.8",
|
||||
"sass": "^1.77.5",
|
||||
"svelte": "^4.2.18",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
7
niimblue/public/icon.svg
Normal file
7
niimblue/public/icon.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="256" height="256" version="1.1" viewBox="0 0 67.733 67.733" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-width="6.0653">
|
||||
<rect x=".75816" y="1.0109" width="66.339" height="65.834" ry="4.9912" fill="#fff"/>
|
||||
<path d="m52.711 52.935q0 0.79237-0.27561 1.4125-0.27561 0.62012-0.75792 1.0335-0.44786 0.41341-1.1024 0.62012-0.62012 0.17225-1.2747 0.17225h-3.8585q-1.2058 0-2.1015-0.24116-0.86128-0.24116-1.6192-0.86128-0.72347-0.65457-1.4125-1.7225-0.68902-1.1024-1.5503-2.825l-11.093-20.843q-0.96463-1.8604-1.9637-3.9963-0.99908-2.1704-1.7915-4.203h-0.0689q0.1378 2.4805 0.20671 4.9609 0.0689 2.446 0.0689 5.0643v23.323q0 0.34451-0.20671 0.62012-0.17226 0.27561-0.65457 0.48231-0.44786 0.17225-1.2402 0.27561-0.79237 0.10335-2.0326 0.10335-1.2058 0-1.9982-0.10335-0.79237-0.10335-1.2402-0.27561-0.44786-0.20671-0.62012-0.48231t-0.17226-0.62012v-40.066q0-1.6192 0.93018-2.4116 0.96463-0.82682 2.3427-0.82682h4.8576q1.3091 0 2.2049 0.24116 0.89573 0.20671 1.5847 0.72347 0.72347 0.51676 1.3436 1.4469 0.62012 0.89573 1.2747 2.2393l8.6816 16.295q0.75792 1.4814 1.4814 2.9283 0.75792 1.4125 1.4469 2.8594 0.68902 1.4125 1.3436 2.7905 0.65457 1.378 1.2747 2.7561h0.03445q-0.10335-2.4116-0.17225-5.0298-0.034451-2.6183-0.034451-4.9954v-20.912q0-0.34451 0.20671-0.62012 0.20671-0.27561 0.68902-0.48231 0.48231-0.20671 1.2747-0.27561 0.79237-0.10335 2.0326-0.10335 1.1713 0 1.9637 0.10335 0.79237 0.0689 1.2058 0.27561 0.44786 0.20671 0.62012 0.48231 0.17226 0.27561 0.17226 0.62012z" fill="#2d90ff" aria-label="N"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
20
niimblue/src/App.svelte
Normal file
20
niimblue/src/App.svelte
Normal file
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import MainPage from "./MainPage.svelte";
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
let loading: boolean = true;
|
||||
|
||||
onMount(() => {
|
||||
import("@fortawesome/free-solid-svg-icons").then((o) => {
|
||||
library.add(o.fas);
|
||||
loading = false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="container my-2">Loading resources...</div>
|
||||
{:else}
|
||||
<MainPage />
|
||||
{/if}
|
47
niimblue/src/MainPage.svelte
Normal file
47
niimblue/src/MainPage.svelte
Normal file
@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import BrowserWarning from "./lib/BrowserWarning.svelte";
|
||||
import ImageEditor from "./lib/ImageEditor.svelte";
|
||||
import PrinterConnector from "./lib/PrinterConnector.svelte";
|
||||
</script>
|
||||
|
||||
<div class="container my-2">
|
||||
<div class="row mb-2">
|
||||
<div class="col">
|
||||
<h1 class="title">
|
||||
<span class="niim">Niim</span><span class="blue">Blue</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<PrinterConnector />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<BrowserWarning/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<ImageEditor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- svelte-ignore missing-declaration -->
|
||||
<div class="version text-secondary">v{__APP_VERSION__} built at {__BUILD_DATE__}</div>
|
||||
|
||||
<style>
|
||||
.niim {
|
||||
color: #ff5349;
|
||||
}
|
||||
.blue {
|
||||
color: #0b7eff;
|
||||
}
|
||||
.version {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
10
niimblue/src/index.ts
Normal file
10
niimblue/src/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import "./style.scss";
|
||||
import "@popperjs/core";
|
||||
import "bootstrap/js/dist/dropdown";
|
||||
import App from "./App.svelte";
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById("app")!,
|
||||
});
|
||||
|
||||
export default app;
|
20
niimblue/src/lib/BrowserWarning.svelte
Normal file
20
niimblue/src/lib/BrowserWarning.svelte
Normal file
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Utils } from "@mmote/niimbluelib";
|
||||
import FaIcon from "./FaIcon.svelte";
|
||||
let bluetooth = Utils.isBluetoothSupported();
|
||||
let serial = Utils.isSerialSupported();
|
||||
</script>
|
||||
|
||||
{#if !bluetooth && !serial}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<div>
|
||||
Oh no, your browser does not support bluetooth and serial communications <FaIcon icon="face-frown-open"/>
|
||||
</div>
|
||||
<div>
|
||||
Anyway, you still can draw labels.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
</style>
|
25
niimblue/src/lib/FaIcon.svelte
Normal file
25
niimblue/src/lib/FaIcon.svelte
Normal file
@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { type IconName, type IconParams, icon as faIcon, parse as faParse } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
export let icon: IconName;
|
||||
export let params: IconParams | undefined = undefined;
|
||||
|
||||
let iconRef: HTMLElement;
|
||||
|
||||
onMount(() => {
|
||||
const lookup = faParse.icon(icon);
|
||||
const iconData = faIcon(lookup, params);
|
||||
|
||||
if (iconData === undefined) {
|
||||
iconRef.innerHTML = "err";
|
||||
return;
|
||||
}
|
||||
|
||||
Array.from(iconData.node).forEach((el: Element) => {
|
||||
iconRef.appendChild(el);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<i class="icon" bind:this={iconRef}></i>
|
51
niimblue/src/lib/IconPicker.svelte
Normal file
51
niimblue/src/lib/IconPicker.svelte
Normal file
@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import FaIcon from "./FaIcon.svelte";
|
||||
import { type IconName } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
export let onSubmit: (i: IconName) => void;
|
||||
|
||||
let iconNames: IconName[] = [];
|
||||
let search: string = "";
|
||||
|
||||
onMount(async () => {
|
||||
const { fas } = await import("@fortawesome/free-solid-svg-icons");
|
||||
|
||||
iconNames = Object.values(fas)
|
||||
.map((e) => e.iconName)
|
||||
.filter((value, index, array) => array.indexOf(value) === index);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||
<FaIcon icon="face-laugh" />
|
||||
<FaIcon icon="plus" />
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu">
|
||||
<h6 class="dropdown-header">Add icon</h6>
|
||||
<div class="p-3">
|
||||
<input type="text" class="form-control" placeholder="Search" bind:value={search} />
|
||||
<div class="icons">
|
||||
{#each iconNames as name}
|
||||
{#if !search || name.includes(search.toLowerCase())}
|
||||
<button class="btn me-1" title={name} on:click={() => onSubmit(name)}>
|
||||
<FaIcon icon={name} />
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dropdown-menu {
|
||||
min-width: 400px;
|
||||
}
|
||||
.icons {
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
314
niimblue/src/lib/ImageEditor.svelte
Normal file
314
niimblue/src/lib/ImageEditor.svelte
Normal file
@ -0,0 +1,314 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { fabric } from "fabric";
|
||||
import { type LabelProps, type OjectType } from "../types";
|
||||
import LabelPropsEditor from "./LabelPropsEditor.svelte";
|
||||
import IconPicker from "./IconPicker.svelte";
|
||||
import { type IconName, icon as faIcon, parse as faParse } from "@fortawesome/fontawesome-svg-core";
|
||||
import ObjectPicker from "./ObjectPicker.svelte";
|
||||
import FaIcon from "./FaIcon.svelte";
|
||||
import PrintPreview from "./PrintPreview.svelte";
|
||||
import TextParamsPanel from "./TextParamsControls.svelte";
|
||||
|
||||
let htmlCanvas: HTMLCanvasElement;
|
||||
let fabricCanvas: fabric.Canvas;
|
||||
let labelProps: LabelProps = { startPos: "left", size: { width: 240, height: 96 } };
|
||||
let previewOpened: boolean = false;
|
||||
let selectedObject: fabric.Object | undefined = undefined;
|
||||
let selectedCount: number = 0;
|
||||
|
||||
const defaultSize = 64;
|
||||
|
||||
const fabricObjectDefaults = {
|
||||
fill: "black",
|
||||
snapAngle: 10,
|
||||
top: 10,
|
||||
left: 10,
|
||||
// objectCaching: false
|
||||
strokeUniform: true,
|
||||
// noScaleCache: true,
|
||||
};
|
||||
|
||||
const deleteSelected = () => {
|
||||
fabricCanvas.getActiveObjects().forEach((obj) => {
|
||||
fabricCanvas.remove(obj);
|
||||
});
|
||||
selectedObject = undefined;
|
||||
selectedCount = 0;
|
||||
fabricCanvas.discardActiveObject();
|
||||
};
|
||||
|
||||
const cloneSelected = () => {
|
||||
if (selectedObject) {
|
||||
selectedObject.clone((obj: fabric.Object) => {
|
||||
obj.snapAngle = 10;
|
||||
obj.top! += 5;
|
||||
obj.left! += 5;
|
||||
fabricCanvas.add(obj);
|
||||
fabricCanvas.setActiveObject(obj);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.repeat) return;
|
||||
if (e.key === "Delete") {
|
||||
// todo: fix del in text editing
|
||||
deleteSelected();
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateLabelProps = () => {
|
||||
labelProps = labelProps; // trigger update
|
||||
fabricCanvas.setDimensions(labelProps.size);
|
||||
};
|
||||
|
||||
const onSaveClicked = () => {
|
||||
const data = fabricCanvas.toJSON();
|
||||
localStorage.setItem("canvas_data", JSON.stringify(data));
|
||||
localStorage.setItem("canvas_props", JSON.stringify(labelProps));
|
||||
};
|
||||
|
||||
const onLoadClicked = () => {
|
||||
const props = localStorage.getItem("canvas_props");
|
||||
if (props) {
|
||||
const parsedProps = JSON.parse(props);
|
||||
labelProps = parsedProps;
|
||||
onUpdateLabelProps();
|
||||
}
|
||||
|
||||
const data = localStorage.getItem("canvas_data");
|
||||
fabricCanvas.loadFromJSON(
|
||||
data,
|
||||
() => {
|
||||
fabricCanvas.requestRenderAll();
|
||||
},
|
||||
(src: object, obj: fabric.Object, error: any) => {
|
||||
obj.set({ snapAngle: 10 });
|
||||
// console.log(error);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const onObjectPicked = (name: OjectType) => {
|
||||
if (name === "text") {
|
||||
const obj = new fabric.IText("Text", {
|
||||
...fabricObjectDefaults,
|
||||
fontFamily: "Arial",
|
||||
});
|
||||
fabricCanvas.add(obj);
|
||||
} else if (name === "line") {
|
||||
const obj = new fabric.Line([10, 10, 10 + defaultSize, 10], {
|
||||
...fabricObjectDefaults,
|
||||
stroke: "#000",
|
||||
strokeWidth: 3,
|
||||
});
|
||||
obj.setControlsVisibility({
|
||||
tl: false,
|
||||
bl: false,
|
||||
tr: false,
|
||||
br: false,
|
||||
mt: false,
|
||||
mb: false,
|
||||
});
|
||||
|
||||
fabricCanvas.add(obj);
|
||||
} else if (name === "circle") {
|
||||
const obj = new fabric.Circle({
|
||||
...fabricObjectDefaults,
|
||||
radius: 25,
|
||||
fill: "transparent",
|
||||
stroke: "black",
|
||||
strokeWidth: 3,
|
||||
});
|
||||
fabricCanvas.add(obj);
|
||||
} else if (name === "rectangle") {
|
||||
const obj = new fabric.Rect({
|
||||
...fabricObjectDefaults,
|
||||
width: defaultSize,
|
||||
height: defaultSize,
|
||||
fill: "transparent",
|
||||
stroke: "black",
|
||||
strokeWidth: 3,
|
||||
});
|
||||
fabricCanvas.add(obj);
|
||||
}
|
||||
};
|
||||
|
||||
const addSvgXmlToCanvas = (data: string) => {
|
||||
fabric.loadSVGFromString(data, (objects, options) => {
|
||||
const obj = fabric.util.groupSVGElements(objects, options);
|
||||
fabric.Text;
|
||||
obj.scaleToWidth(defaultSize).scaleToHeight(defaultSize);
|
||||
obj.set({ ...fabricObjectDefaults, top: 0, left: 0 });
|
||||
|
||||
fabricCanvas.add(obj).renderAll();
|
||||
fabricCanvas.renderAll();
|
||||
});
|
||||
};
|
||||
|
||||
const onIconPicked = (i: IconName) => {
|
||||
const lookup = faParse.icon(i);
|
||||
const iconData = faIcon(lookup);
|
||||
|
||||
if (iconData === undefined) {
|
||||
console.error(`Icon ${i} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
addSvgXmlToCanvas(iconData.html.toString());
|
||||
};
|
||||
|
||||
const onPreviewClosed = () => {
|
||||
previewOpened = false;
|
||||
};
|
||||
|
||||
const openPreview = () => {
|
||||
previewOpened = true;
|
||||
};
|
||||
|
||||
const getImageForPreview = (): string => {
|
||||
return fabricCanvas.toDataURL({ format: "png" });
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
fabricCanvas = new fabric.Canvas(htmlCanvas, {
|
||||
width: labelProps.size.width,
|
||||
height: labelProps.size.height,
|
||||
backgroundColor: "#fff",
|
||||
});
|
||||
|
||||
fabricCanvas.on("object:moving", (e: fabric.IEvent<MouseEvent>) => {
|
||||
const grid = 5;
|
||||
if (e.target && e.target.left !== undefined && e.target.top !== undefined) {
|
||||
e.target.set({
|
||||
left: Math.round(e.target.left / grid) * grid,
|
||||
top: Math.round(e.target.top / grid) * grid,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
fabricCanvas.on("selection:created", (e: fabric.IEvent<MouseEvent>): void => {
|
||||
selectedCount = e.selected?.length ?? 0;
|
||||
selectedObject = e.selected?.length === 1 ? e.selected[0] : undefined;
|
||||
});
|
||||
|
||||
fabricCanvas.on("selection:updated", (e: fabric.IEvent<MouseEvent>): void => {
|
||||
selectedCount = e.selected?.length ?? 0;
|
||||
selectedObject = e.selected?.length === 1 ? e.selected[0] : undefined;
|
||||
});
|
||||
|
||||
fabricCanvas.on("selection:cleared", (): void => {
|
||||
selectedObject = undefined;
|
||||
selectedCount = 0;
|
||||
});
|
||||
|
||||
fabricCanvas.on("drop", (e: fabric.IEvent<MouseEvent>): void => {
|
||||
const dragEvt = e.e as DragEvent;
|
||||
dragEvt.preventDefault();
|
||||
|
||||
if (dragEvt.dataTransfer?.files) {
|
||||
[...dragEvt.dataTransfer.files].forEach((file: File, idx: number) => {
|
||||
const reader = new FileReader();
|
||||
console.log(file.type);
|
||||
if (file.type.startsWith("image/svg")) {
|
||||
reader.readAsText(file, "UTF-8");
|
||||
reader.onload = (readerEvt: ProgressEvent<FileReader>) => {
|
||||
if (readerEvt.target && readerEvt.target.result) {
|
||||
addSvgXmlToCanvas(readerEvt.target.result.toString());
|
||||
}
|
||||
};
|
||||
reader.onerror = (readerEvt: ProgressEvent<FileReader>) => {
|
||||
console.error(readerEvt);
|
||||
};
|
||||
} else if (file.type === "image/png" || file.type === "image/jpeg") {
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (readerEvt: ProgressEvent<FileReader>) => {
|
||||
if (readerEvt.target && readerEvt.target.result) {
|
||||
fabric.Image.fromURL(readerEvt.target.result.toString(), (img: fabric.Image) => {
|
||||
img.set({ left: 0, top: 0, snapAngle:10 });
|
||||
img.scaleToHeight(defaultSize).scaleToHeight(defaultSize);
|
||||
fabricCanvas.add(img);
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.onerror = (readerEvt: ProgressEvent<FileReader>) => {
|
||||
console.error(readerEvt);
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
fabricCanvas.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeyDown} />
|
||||
|
||||
<div class="image-editor">
|
||||
<!-- <div class="row mb-1">
|
||||
<div class="col d-flex justify-content-center">
|
||||
<div class="toolbar mb-1 d-flex flex-wrap gap-1 justify-content-center align-items-center">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="row mb-1">
|
||||
<div class="col d-flex justify-content-center">
|
||||
<div class="canvas-wrapper print-start-{labelProps.startPos}">
|
||||
<canvas bind:this={htmlCanvas}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-1">
|
||||
<div class="col d-flex justify-content-center">
|
||||
<div class="toolbar d-flex flex-wrap gap-1 justify-content-center align-items-center">
|
||||
<LabelPropsEditor {labelProps} onChange={onUpdateLabelProps} />
|
||||
|
||||
<button class="btn btn-secondary btn-sm" on:click={onSaveClicked}><FaIcon icon="floppy-disk" /></button>
|
||||
<button class="btn btn-secondary btn-sm" on:click={onLoadClicked}><FaIcon icon="folder-open" /></button>
|
||||
|
||||
<IconPicker onSubmit={onIconPicked} />
|
||||
<ObjectPicker onSubmit={onObjectPicked} />
|
||||
|
||||
<button class="btn btn-sm btn-primary ms-1" on:click={openPreview}><FaIcon icon="print" /> Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-1">
|
||||
<div class="col d-flex justify-content-center">
|
||||
<div class="toolbar d-flex flex-wrap gap-1 justify-content-center align-items-center">
|
||||
{#if selectedCount > 0}
|
||||
<button class="btn btn-sm btn-danger me-1" on:click={deleteSelected}><FaIcon icon="trash" /></button>
|
||||
{/if}
|
||||
|
||||
{#if selectedCount === 1}
|
||||
<button class="btn btn-sm btn-secondary me-1" on:click={cloneSelected}><FaIcon icon="clone" /></button>
|
||||
{/if}
|
||||
|
||||
{#if selectedObject instanceof fabric.IText}
|
||||
<TextParamsPanel {selectedObject} valueUpdated={() => fabricCanvas.requestRenderAll()} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if previewOpened}
|
||||
<PrintPreview onClosed={onPreviewClosed} imageCallback={getImageForPreview} {labelProps} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.canvas-wrapper.print-start-left {
|
||||
border-left: 2px solid #ff4646;
|
||||
}
|
||||
.canvas-wrapper.print-start-top {
|
||||
border-top: 2px solid #ff4646;
|
||||
}
|
||||
</style>
|
82
niimblue/src/lib/LabelPresetsBrowser.svelte
Normal file
82
niimblue/src/lib/LabelPresetsBrowser.svelte
Normal file
@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { LabelPreset } from "../types";
|
||||
|
||||
export let onItemSelected: (preset: LabelPreset) => void;
|
||||
|
||||
// <option value=""></option>
|
||||
// <option value="L:1:240x96">D# 30x15mm | 8dpmm (203dpi) | Left</option>
|
||||
// <option value="L:1:320x96">D# 40x15mm | 8dpmm (203dpi) | Left</option>
|
||||
// <option value="T:1:96x320">D# 40x15mm | 8dpmm (203dpi) | Top</option>
|
||||
// <option value="T:8:30x20">B# 30x20mm | 8dpmm (203dpi) | Top</option>
|
||||
// <option value="T:8:40x30">B# 40x30mm | 8dpmm (203dpi) | Top</option>
|
||||
// <option value="T:8:40x70">B# 40x70mm | 8dpmm (203dpi) | Top</option>
|
||||
// <option value="T:8:43x25">B# 43x25mm | 8dpmm (203dpi) | Top</option>
|
||||
// <option value="T:8:50x30">B# 50x30mm | 8dpmm (203dpi) | Top</option>
|
||||
// <option value="T:8:50x40">B# 50x40mm | 8dpmm (203dpi) | Top</option>
|
||||
|
||||
const presets: LabelPreset[] = [
|
||||
{ width: 30, height: 12, unit: "mm", dpmm: 8, startPosition: "left" },
|
||||
{ width: 40, height: 12, unit: "mm", dpmm: 8, startPosition: "left" },
|
||||
{ width: 12, height: 40, unit: "mm", dpmm: 8, startPosition: "top" },
|
||||
{ width: 30, height: 20, unit: "mm", dpmm: 8, startPosition: "top" },
|
||||
{ width: 40, height: 30, unit: "mm", dpmm: 8, startPosition: "top" },
|
||||
{ width: 40, height: 70, unit: "mm", dpmm: 8, startPosition: "top" },
|
||||
{ width: 43, height: 25, unit: "mm", dpmm: 8, startPosition: "top" },
|
||||
{ width: 50, height: 30, unit: "mm", dpmm: 8, startPosition: "top" },
|
||||
{ width: 50, height: 40, unit: "mm", dpmm: 8, startPosition: "top" },
|
||||
];
|
||||
|
||||
const scaleDimensions = (preset: LabelPreset): { width: number; height: number } => {
|
||||
const scaleFactor = Math.min(100 / preset.width, 100 / preset.height);
|
||||
return {
|
||||
width: Math.round(preset.width * scaleFactor),
|
||||
height: Math.round(preset.height * scaleFactor),
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="preset-browser overflow-y-auto border d-flex gap-1 flex-wrap {$$props.class}">
|
||||
{#each presets as item}
|
||||
<button
|
||||
class="btn p-0 card-wrapper d-flex justify-content-center align-items-center"
|
||||
on:click={() => onItemSelected(item)}
|
||||
>
|
||||
<div
|
||||
class="card print-start-{item.startPosition} d-flex justify-content-center align-items-center"
|
||||
style="width: {scaleDimensions(item).width}%; height: {scaleDimensions(item).height}%;"
|
||||
>
|
||||
<span class="label p-1">
|
||||
{item.width}x{item.height}{item.unit}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.preset-browser {
|
||||
max-height: 200px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-wrapper {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: white;
|
||||
}
|
||||
.card > .label {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
color: black;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card.print-start-left {
|
||||
border-left: 2px solid #ff4646;
|
||||
}
|
||||
.card.print-start-top {
|
||||
border-top: 2px solid #ff4646;
|
||||
}
|
||||
</style>
|
106
niimblue/src/lib/LabelPropsEditor.svelte
Normal file
106
niimblue/src/lib/LabelPropsEditor.svelte
Normal file
@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import FaIcon from "./FaIcon.svelte";
|
||||
import { type LabelPreset, type LabelProps } from "../types";
|
||||
import LabelPresetsBrowser from "./LabelPresetsBrowser.svelte";
|
||||
|
||||
export let labelProps: LabelProps;
|
||||
export let onChange: () => void;
|
||||
let dpmm = 8;
|
||||
let sizeMm: { width?: number; height?: number; dpmm: number } = { width: undefined, height: undefined, dpmm: 8 };
|
||||
|
||||
const onChangePre = () => {
|
||||
labelProps.size.width = labelProps.size.width < dpmm ? dpmm : labelProps.size.width;
|
||||
labelProps.size.height = labelProps.size.height < dpmm ? dpmm : labelProps.size.height;
|
||||
|
||||
if (labelProps.startPos === "left") {
|
||||
labelProps.size.height -= labelProps.size.height % dpmm;
|
||||
} else {
|
||||
labelProps.size.width -= labelProps.size.width % dpmm;
|
||||
}
|
||||
onChange();
|
||||
};
|
||||
|
||||
const onLabelPresetSelected = (preset: LabelPreset) => {
|
||||
labelProps.startPos = preset.startPosition;
|
||||
labelProps.size.width = preset.width * preset.dpmm;
|
||||
labelProps.size.height = preset.height * preset.dpmm;
|
||||
|
||||
onChangePre();
|
||||
};
|
||||
|
||||
const onCalcSize = () => {
|
||||
if (sizeMm.height && sizeMm.width) {
|
||||
labelProps.size.width = sizeMm.width * sizeMm.dpmm;
|
||||
labelProps.size.height = sizeMm.height * sizeMm.dpmm;
|
||||
}
|
||||
onChangePre();
|
||||
};
|
||||
|
||||
const onFlip = () => {
|
||||
let width = labelProps.size.width;
|
||||
labelProps.size.width = labelProps.size.height;
|
||||
labelProps.size.height = width;
|
||||
labelProps.startPos = labelProps.startPos === "top" ? "left" : "top";
|
||||
onChangePre();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||
<FaIcon icon="gear" />
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<h6 class="dropdown-header">Label properties</h6>
|
||||
<div class="p-3">
|
||||
<LabelPresetsBrowser class="mb-1" onItemSelected={onLabelPresetSelected} />
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm mb-3">
|
||||
<span class="input-group-text">Size</span>
|
||||
<input class="form-control" type="number" min="0" bind:value={sizeMm.width} on:change={onChangePre} />
|
||||
<span class="input-group-text">x</span>
|
||||
<input class="form-control" type="number" min="0" bind:value={sizeMm.height} on:change={onChangePre} />
|
||||
<span class="input-group-text">mm</span>
|
||||
<input class="form-control" type="number" min="0" bind:value={sizeMm.dpmm} on:change={onChangePre} />
|
||||
<span class="input-group-text">dpmm</span>
|
||||
<button class="btn btn-sm btn-primary" on:click={onCalcSize}>Calc</button>
|
||||
</div>
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm mb-3">
|
||||
<span class="input-group-text">Size</span>
|
||||
<input
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="0"
|
||||
step={dpmm}
|
||||
bind:value={labelProps.size.width}
|
||||
on:change={onChangePre}
|
||||
/>
|
||||
<button class="btn btn-sm btn-secondary" on:click={onFlip}><FaIcon icon="repeat" /></button>
|
||||
<input
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="0"
|
||||
step={dpmm}
|
||||
bind:value={labelProps.size.height}
|
||||
on:change={onChangePre}
|
||||
/>
|
||||
<span class="input-group-text">px</span>
|
||||
</div>
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm mb-3">
|
||||
<span class="input-group-text">Print from</span>
|
||||
<select class="form-select" bind:value={labelProps.startPos} on:change={onChangePre}>
|
||||
<option value="left">Left</option>
|
||||
<option value="top">Top</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- <button class="btn btn-sm btn-primary" on:click={onSubmit}>Update</button> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dropdown-menu {
|
||||
min-width: 400px;
|
||||
}
|
||||
</style>
|
37
niimblue/src/lib/ObjectPicker.svelte
Normal file
37
niimblue/src/lib/ObjectPicker.svelte
Normal file
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import FaIcon from "./FaIcon.svelte";
|
||||
import { type OjectType } from "../types";
|
||||
|
||||
export let onSubmit: (i: OjectType) => void;
|
||||
</script>
|
||||
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||
<FaIcon icon="vector-square" />
|
||||
<FaIcon icon="plus" />
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu">
|
||||
<h6 class="dropdown-header">Add object</h6>
|
||||
<div class="p-3">
|
||||
<button class="btn me-1" on:click={() => onSubmit("text")}>
|
||||
<FaIcon icon="font" /> Text
|
||||
</button>
|
||||
<button class="btn me-1" on:click={() => onSubmit("line")}>
|
||||
<FaIcon icon="minus" /> Line
|
||||
</button>
|
||||
<button class="btn me-1" on:click={() => onSubmit("rectangle")}>
|
||||
<FaIcon icon="vector-square" /> Rectangle
|
||||
</button>
|
||||
<button class="btn me-1" on:click={() => onSubmit("circle")}>
|
||||
<FaIcon icon="circle-dot" /> Circle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dropdown-menu {
|
||||
min-width: 400px;
|
||||
}
|
||||
</style>
|
190
niimblue/src/lib/PrintPreview.svelte
Normal file
190
niimblue/src/lib/PrintPreview.svelte
Normal file
@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { derived } from "svelte/store";
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import { connectionState, printerClient } from "../stores";
|
||||
import { copyImageData, threshold, atkinson } from "../post_process";
|
||||
import { type EncodedImage, ImageEncoder, Utils, LabelType, PacketGenerator, PrintSequenceVariant } from "@mmote/niimbluelib";
|
||||
import type { LabelProps } from "../types";
|
||||
import FaIcon from "./FaIcon.svelte";
|
||||
|
||||
export let onClosed: () => void;
|
||||
export let labelProps: LabelProps;
|
||||
|
||||
export let imageCallback: () => string;
|
||||
|
||||
let modalElement: HTMLElement;
|
||||
let previewCanvas: HTMLCanvasElement;
|
||||
let modal: Modal;
|
||||
let sendProgress: number = 0;
|
||||
let density: number = 3;
|
||||
let quantity: number = 1;
|
||||
let printed: boolean = false;
|
||||
let postProcessType: "threshold" | "dither";
|
||||
let thresholdValue: number = 140;
|
||||
let imgData: ImageData;
|
||||
let imgContext: CanvasRenderingContext2D;
|
||||
let protoVariant: PrintSequenceVariant = PrintSequenceVariant.V1;
|
||||
|
||||
const disconnected = derived(connectionState, ($connectionState) => $connectionState !== "connected");
|
||||
|
||||
const onPrint = async () => {
|
||||
const encoded: EncodedImage = ImageEncoder.encodeCanvas(previewCanvas, labelProps.startPos);
|
||||
const packets = PacketGenerator.generatePrintSequence(protoVariant, encoded, { quantity, density });
|
||||
for (let i = 0; i < packets.length; i++) {
|
||||
sendProgress = Math.round(((i + 1) / packets.length) * 100);
|
||||
// console.log(Utils.bufToHex(packets[i].toBytes()))
|
||||
await $printerClient.sendPacket(packets[i]);
|
||||
|
||||
// await Utils.sleep(100);
|
||||
// console.log(Utils.bufToHex(packets[i].toBytes()))
|
||||
}
|
||||
printed = true;
|
||||
};
|
||||
|
||||
const onEndPrint = async () => {
|
||||
if (!$disconnected && printed) {
|
||||
await $printerClient.sendPacket(PacketGenerator.printEnd());
|
||||
printed = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updatePreview = () => {
|
||||
let iData: ImageData = copyImageData(imgData);
|
||||
|
||||
if (postProcessType === "threshold") {
|
||||
iData = threshold(iData, thresholdValue);
|
||||
} else if (postProcessType === "dither") {
|
||||
iData = atkinson(iData, thresholdValue);
|
||||
}
|
||||
|
||||
imgContext.putImageData(iData, 0, 0);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
previewCanvas.width = img.width;
|
||||
previewCanvas.height = img.height;
|
||||
|
||||
imgContext = previewCanvas.getContext("2d")!;
|
||||
imgContext.drawImage(img, 0, 0, img.width, img.height);
|
||||
imgData = imgContext.getImageData(0, 0, img.width, img.height);
|
||||
|
||||
updatePreview();
|
||||
};
|
||||
img.src = imageCallback();
|
||||
|
||||
modal = new Modal(modalElement);
|
||||
modal.show();
|
||||
modalElement.addEventListener("hidden.bs.modal", async () => {
|
||||
onEndPrint();
|
||||
onClosed();
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
modal.dispose();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={modalElement}
|
||||
class="modal fade"
|
||||
id="exampleModal"
|
||||
tabindex="-1"
|
||||
aria-labelledby="exampleModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="exampleModalLabel">Print preview</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body text-center">
|
||||
<canvas class="print-start-{labelProps.startPos}" bind:this={previewCanvas}></canvas>
|
||||
|
||||
{#if sendProgress != 0 && sendProgress != 100}
|
||||
<div>
|
||||
Sending...
|
||||
<div class="progress" role="progressbar">
|
||||
<div class="progress-bar" style="width: {sendProgress}%">{sendProgress}%</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">Post-process</span>
|
||||
|
||||
<select class="form-select" bind:value={postProcessType} on:change={updatePreview}>
|
||||
<option value="threshold">Threshold</option>
|
||||
<option value="dither">Dither (Atkinson)</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
id="threshold"
|
||||
class="form-range"
|
||||
min="1"
|
||||
max="255"
|
||||
bind:value={thresholdValue}
|
||||
on:change={updatePreview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm">
|
||||
<span class="input-group-text">Density</span>
|
||||
<input class="form-control" type="number" min="1" max="6" bind:value={density} />
|
||||
</div>
|
||||
|
||||
<!-- <div class="input-group flex-nowrap input-group-sm">
|
||||
<span class="input-group-text">Quantity</span>
|
||||
<input class="form-control" type="number" min="1" bind:value={quantity} />
|
||||
</div> -->
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">Protocol variant</span>
|
||||
<select class="form-select" bind:value={protoVariant}>
|
||||
<option value="{PrintSequenceVariant.V1}">V1 - D110</option>
|
||||
<option value="{PrintSequenceVariant.V2}">V2 - B1</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
|
||||
{#if printed}
|
||||
<button type="button" class="btn btn-primary" disabled={$disconnected} on:click={onEndPrint}>
|
||||
End print
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="btn btn-primary" disabled={$disconnected || printed} on:click={onPrint}>
|
||||
{#if $disconnected}
|
||||
Printer is not connected
|
||||
{:else}
|
||||
<FaIcon icon="print" /> Print
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
border: 1px solid #6d6d6d;
|
||||
}
|
||||
canvas.print-start-left {
|
||||
border-left: 2px solid #ff4646;
|
||||
}
|
||||
canvas.print-start-top {
|
||||
border-top: 2px solid #ff4646;
|
||||
}
|
||||
</style>
|
91
niimblue/src/lib/PrinterConnector.svelte
Normal file
91
niimblue/src/lib/PrinterConnector.svelte
Normal file
@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
PacketGenerator,
|
||||
PrinterInfoType,
|
||||
HeartbeatType,
|
||||
PrinterId,
|
||||
type PrinterInfoPrinterCodeDecoded,
|
||||
} from "@mmote/niimbluelib";
|
||||
import { printerClient, connectedPrinterName, connectionState, initClient } from "../stores";
|
||||
import type { ConnectionType } from "../types";
|
||||
import FaIcon from "./FaIcon.svelte";
|
||||
|
||||
let connectionType: ConnectionType = "bluetooth";
|
||||
let timer: NodeJS.Timeout | undefined = undefined;
|
||||
const onConnectClicked = async () => {
|
||||
initClient(connectionType);
|
||||
connectionState.set("connecting");
|
||||
|
||||
try {
|
||||
await $printerClient.connect();
|
||||
} catch (e) {
|
||||
connectionState.set("disconnected");
|
||||
alert(e);
|
||||
}
|
||||
};
|
||||
const onDisconnectClicked = () => {
|
||||
$printerClient.disconnect();
|
||||
};
|
||||
const getRfidInfo = async () => {
|
||||
const data = await $printerClient.sendPacketWaitResponseDecoded(PacketGenerator.rfidInfo());
|
||||
alert(JSON.stringify(data, null, 2));
|
||||
|
||||
// const data = Uint8Array.of(1,1,1,1);
|
||||
// await $printerClient.sendPacketWaitResponse(PacketGenerator.writeRfid(data), 1000);
|
||||
// await $printerClient.sendPacketWaitResponse(PacketGenerator.rfidInfo(), 1000);
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
timer = setInterval(async () => {
|
||||
const data = await $printerClient.sendPacketWaitResponseDecoded(
|
||||
PacketGenerator.heartbeat(HeartbeatType.Unknown1)
|
||||
);
|
||||
}, 1000);
|
||||
};
|
||||
const test2 = async () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
|
||||
const test3 = async () => {
|
||||
const info = (await $printerClient.sendPacketWaitResponseDecoded(
|
||||
PacketGenerator.getPrinterInfo(PrinterInfoType.PrinterCode)
|
||||
)) as PrinterInfoPrinterCodeDecoded;
|
||||
const id: string | undefined = PrinterId[info.code];
|
||||
alert(`Printer model: ${id ?? "Unknown"}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="input-group flex-nowrap justify-content-end">
|
||||
{#if $connectionState === "connected"}
|
||||
<button class="btn btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside"
|
||||
><FaIcon icon="gear" />
|
||||
</button>
|
||||
<div class="dropdown-menu p-1">
|
||||
<button class="btn btn-sm btn-primary" on:click={getRfidInfo}>RfidInfo</button>
|
||||
<button class="btn btn-sm btn-primary" on:click={test}>start heartbeat (test)</button>
|
||||
<button class="btn btn-sm btn-primary" on:click={test2}>stop heartbeat (test)</button>
|
||||
<button class="btn btn-sm btn-primary" on:click={test3}>Guess printer model</button>
|
||||
</div>
|
||||
<span class="input-group-text">{$connectedPrinterName}</span>
|
||||
{:else}
|
||||
<select class="form-select" disabled={$connectionState === "connecting"} bind:value={connectionType}>
|
||||
<option value="bluetooth">Bluetooth</option>
|
||||
<option value="serial">Serial (WIP)</option>
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
{#if $connectionState !== "connected"}
|
||||
<button class="btn btn-primary" disabled={$connectionState === "connecting"} on:click={onConnectClicked}>
|
||||
<FaIcon icon="plug" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if $connectionState === "connected"}
|
||||
<button class="btn btn-danger" on:click={onDisconnectClicked}>
|
||||
<FaIcon icon="plug-circle-xmark" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
141
niimblue/src/lib/TextParamsControls.svelte
Normal file
141
niimblue/src/lib/TextParamsControls.svelte
Normal file
@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { fabric } from "fabric";
|
||||
import FaIcon from "./FaIcon.svelte";
|
||||
|
||||
export let selectedObject: fabric.Object;
|
||||
export let valueUpdated: () => void;
|
||||
let selectedText: fabric.IText | undefined;
|
||||
let fontFamilies: string[] = ["Arial"];
|
||||
|
||||
const toggleBold = () => {
|
||||
if (selectedText!.fontWeight === "bold") {
|
||||
selectedText!.fontWeight = "normal";
|
||||
} else {
|
||||
selectedText!.fontWeight = "bold";
|
||||
}
|
||||
|
||||
commit();
|
||||
};
|
||||
|
||||
const setAlign = (align: "left" | "center" | "right") => {
|
||||
selectedText!.textAlign = align;
|
||||
commit();
|
||||
};
|
||||
|
||||
const fontSizeUp = () => {
|
||||
selectedText!.fontSize! += selectedText!.fontSize! > 40 ? 10 : 2;
|
||||
commit();
|
||||
};
|
||||
const fontSizeDown = () => {
|
||||
selectedText!.fontSize! -= selectedText!.fontSize! > 40 ? 10 : 2;
|
||||
commit();
|
||||
};
|
||||
|
||||
const getFonts = async () => {
|
||||
try {
|
||||
const fonts = await queryLocalFonts();
|
||||
fontFamilies = [...new Set(fonts.map((f: FontData) => f.family))].sort();
|
||||
console.log(fontFamilies);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
selectedText!.fontSize = Math.max(selectedText!.fontSize!, 1);
|
||||
valueUpdated();
|
||||
};
|
||||
|
||||
$: {
|
||||
selectedText = selectedObject instanceof fabric.IText ? (selectedObject as fabric.IText) : undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if selectedText}
|
||||
<!-- <div class="d-flex flex-wrap gap-1"> -->
|
||||
<button
|
||||
class="btn btn-sm {selectedText.textAlign === 'left' ? 'btn-secondary' : ''}"
|
||||
on:click={() => setAlign("left")}><FaIcon icon="align-left" /></button
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm {selectedText.textAlign === 'center' ? 'btn-secondary' : ''}"
|
||||
on:click={() => setAlign("center")}><FaIcon icon="align-center" /></button
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm {selectedText.textAlign === 'right' ? 'btn-secondary' : ''}"
|
||||
on:click={() => setAlign("right")}><FaIcon icon="align-right" /></button
|
||||
>
|
||||
<button class="btn btn-sm {selectedText.fontWeight === 'bold' ? 'btn-secondary' : ''}" on:click={toggleBold}>
|
||||
<FaIcon icon="bold" />
|
||||
</button>
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm font-size">
|
||||
<span class="input-group-text" title="Font size"><FaIcon icon="text-height" /></span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="999"
|
||||
step="2"
|
||||
class="form-control"
|
||||
bind:value={selectedText.fontSize}
|
||||
on:input={commit}
|
||||
/>
|
||||
<button class="btn btn-secondary" on:click={fontSizeUp}
|
||||
><span class="fa-layers">
|
||||
<FaIcon icon="font" />
|
||||
<FaIcon icon="caret-up" params={{ transform: { x: 10, y: -5, size: 12 } }} />
|
||||
</span></button
|
||||
>
|
||||
<button class="btn btn-secondary" on:click={fontSizeDown}
|
||||
><span class="fa-layers">
|
||||
<FaIcon icon="font" />
|
||||
<FaIcon icon="caret-down" params={{ transform: { x: 10, y: -5, size: 12 } }} />
|
||||
</span></button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm">
|
||||
<span class="input-group-text" title="Line height"
|
||||
><FaIcon icon="arrows-left-right-to-line" params={{ transform: { rotate: 90 } }} /></span
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
max="10"
|
||||
class="form-control"
|
||||
bind:value={selectedText.lineHeight}
|
||||
on:input={commit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group flex-nowrap input-group-sm font-family">
|
||||
<span class="input-group-text" title="Font family"><FaIcon icon="font" /></span>
|
||||
<!-- svelte-ignore missing-declaration -->
|
||||
{#if typeof queryLocalFonts !== "undefined"}
|
||||
<select class="form-select" bind:value={selectedText.fontFamily} on:change={commit}>
|
||||
{#each fontFamilies as font}
|
||||
<option value={font}>{font}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="btn btn-secondary" on:click={getFonts}>
|
||||
<FaIcon icon="rotate" />
|
||||
</button>
|
||||
{:else}
|
||||
<input type="text" class="form-control" bind:value={selectedText.fontFamily} on:input={commit} />
|
||||
{/if}
|
||||
</div>
|
||||
<!-- </div> -->
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.input-group {
|
||||
width: 7em;
|
||||
}
|
||||
.font-size {
|
||||
width: 10em;
|
||||
}
|
||||
.font-family {
|
||||
width: 12em;
|
||||
}
|
||||
</style>
|
159
niimblue/src/post_process.ts
Normal file
159
niimblue/src/post_process.ts
Normal file
@ -0,0 +1,159 @@
|
||||
export const copyImageData = (iData: ImageData): ImageData => {
|
||||
return new ImageData(new Uint8ClampedArray(iData.data), iData.width, iData.height);
|
||||
};
|
||||
|
||||
export const convertImgDataToBlackAndWhite = (iData: ImageData, threshold = 0xff): ImageData => {
|
||||
for (let x = 0; x < iData.width; x++) {
|
||||
for (let i = 0; i < iData.data.byteLength / 4; i++) {
|
||||
const pos = i * 4;
|
||||
const b =
|
||||
iData.data[pos] >= threshold || iData.data[pos + 1] >= threshold || iData.data[pos + 2] >= threshold ? 0xff : 0;
|
||||
iData.data[pos] = b;
|
||||
iData.data[pos + 1] = b;
|
||||
iData.data[pos + 2] = b;
|
||||
iData.data[pos + 3] = 0xff;
|
||||
}
|
||||
}
|
||||
|
||||
return iData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the image to blank and white using a simple threshold
|
||||
*
|
||||
* @param {object} image The imageData of a Canvas 2d context
|
||||
* @param {number} threshold Threshold value (0-255)
|
||||
* @return {object} The resulting imageData
|
||||
*
|
||||
*/
|
||||
export const threshold = (image: ImageData, threshold: number): ImageData => {
|
||||
for (let i = 0; i < image.data.length; i += 4) {
|
||||
const luminance = image.data[i] * 0.299 + image.data[i + 1] * 0.587 + image.data[i + 2] * 0.114;
|
||||
|
||||
const value = luminance < threshold ? 0 : 255;
|
||||
image.data.fill(value, i, i + 3);
|
||||
}
|
||||
|
||||
return image;
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the image to blank and white using the Bayer algorithm
|
||||
*
|
||||
* @param {object} image The imageData of a Canvas 2d context
|
||||
* @param {number} threshold Threshold value (0-255)
|
||||
* @return {object} The resulting imageData
|
||||
*
|
||||
*/
|
||||
export const bayer = (image: ImageData, threshold: number): ImageData => {
|
||||
const thresholdMap = [
|
||||
[15, 135, 45, 165],
|
||||
[195, 75, 225, 105],
|
||||
[60, 180, 30, 150],
|
||||
[240, 120, 210, 90],
|
||||
];
|
||||
|
||||
for (let i = 0; i < image.data.length; i += 4) {
|
||||
const luminance = image.data[i] * 0.299 + image.data[i + 1] * 0.587 + image.data[i + 2] * 0.114;
|
||||
|
||||
const x = (i / 4) % image.width;
|
||||
const y = Math.floor(i / 4 / image.width);
|
||||
const map = Math.floor((luminance + thresholdMap[x % 4][y % 4]) / 2);
|
||||
const value = map < threshold ? 0 : 255;
|
||||
image.data.fill(value, i, i + 3);
|
||||
}
|
||||
|
||||
return image;
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the image to blank and white using the Floyd-Steinberg algorithm
|
||||
*
|
||||
* @param {object} image The imageData of a Canvas 2d context
|
||||
* @return {object} The resulting imageData
|
||||
*
|
||||
*/
|
||||
export const floydsteinberg = (image: ImageData, threshold: number): ImageData => {
|
||||
const width = image.width;
|
||||
const luminance = new Uint8ClampedArray(image.width * image.height);
|
||||
|
||||
for (let l = 0, i = 0; i < image.data.length; l++, i += 4) {
|
||||
luminance[l] = image.data[i] * 0.299 + image.data[i + 1] * 0.587 + image.data[i + 2] * 0.114;
|
||||
}
|
||||
|
||||
for (let l = 0, i = 0; i < image.data.length; l++, i += 4) {
|
||||
const value = luminance[l] < threshold ? 0 : 255;
|
||||
const error = Math.floor((luminance[l] - value) / 16);
|
||||
image.data.fill(value, i, i + 3);
|
||||
|
||||
luminance[l + 1] += error * 7;
|
||||
luminance[l + width - 1] += error * 3;
|
||||
luminance[l + width] += error * 5;
|
||||
luminance[l + width + 1] += error * 1;
|
||||
}
|
||||
|
||||
return image;
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the image to blank and white using the Atkinson algorithm
|
||||
*
|
||||
* @param {object} image The imageData of a Canvas 2d context
|
||||
* @return {object} The resulting imageData
|
||||
*
|
||||
*/
|
||||
export const atkinson = (image: ImageData, threshold: number): ImageData => {
|
||||
const src = image.data;
|
||||
const dst = new Uint8ClampedArray(image.width * image.height);
|
||||
|
||||
for (let l = 0, i = 0; i < src.length; l++, i += 4) {
|
||||
dst[l] = src[i] * 0.299 + src[i + 1] * 0.587 + src[i + 2] * 0.114;
|
||||
}
|
||||
|
||||
for (let l = 0, i = 0; i < src.length; l++, i += 4) {
|
||||
const value = dst[l] < threshold ? 0 : 255;
|
||||
const error = Math.floor((dst[l] - value) / 8);
|
||||
src.fill(value, i, i + 3);
|
||||
|
||||
dst[l + 1] += error;
|
||||
dst[l + 2] += error;
|
||||
dst[l + image.width - 1] += error;
|
||||
dst[l + image.width] += error;
|
||||
dst[l + image.width + 1] += error;
|
||||
dst[l + 2 * image.width] += error;
|
||||
}
|
||||
|
||||
return image;
|
||||
};
|
||||
|
||||
// https://observablehq.com/@tmcw/dithering
|
||||
export const atkinson2 = (image: ImageData, threshold: number):ImageData => {
|
||||
let GRAYS = 256;
|
||||
let THRESHOLD = [];
|
||||
for (let i = 0; i < GRAYS; i++) {
|
||||
THRESHOLD.push(i < (GRAYS >> 1) ? 0 : GRAYS - 1);
|
||||
}
|
||||
|
||||
let clone = new ImageData(new Uint8ClampedArray(image.data), image.width, image.height);
|
||||
|
||||
function px(x:number, y:number) {
|
||||
return (x * 4) + (y * image.width * 4);
|
||||
}
|
||||
|
||||
for (let y = 0; y < image.height; y++) {
|
||||
for (let x = 0; x < image.width; x++) {
|
||||
let oldPixel = clone.data[px(x, y)];
|
||||
let grayNew = THRESHOLD[oldPixel];
|
||||
let grayErr = (oldPixel - grayNew) >> 3;
|
||||
let newPixel = oldPixel > 125 ? 255 : 0;
|
||||
clone.data[px(x, y)] = clone.data[px(x, y) + 1] = clone.data[px(x, y) + 2] = newPixel;
|
||||
clone.data[px(x, y)] =
|
||||
clone.data[px(x, y) + 1] =
|
||||
clone.data[px(x, y) + 2] = grayNew;
|
||||
[[1, 0], [2, 0], [-1, 1], [0, 1], [1, 1], [0, 2]].forEach(([dx, dy]) => {
|
||||
clone.data[px(x + dx, y + dy)] += grayErr;
|
||||
});
|
||||
}
|
||||
}
|
||||
return clone;
|
||||
}
|
66
niimblue/src/stores.ts
Normal file
66
niimblue/src/stores.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { ConnectionState, ConnectionType } from "./types";
|
||||
import {
|
||||
ConnectEvent,
|
||||
NiimbotBluetoothClient,
|
||||
NiimbotSerialClient,
|
||||
PacketParsedEvent,
|
||||
RawPacketReceivedEvent,
|
||||
RawPacketSentEvent,
|
||||
Utils,
|
||||
type NiimbotAbstractClient,
|
||||
} from "@mmote/niimbluelib";
|
||||
|
||||
export const connectionState = writable<ConnectionState>("disconnected");
|
||||
export const connectedPrinterName = writable<string>("");
|
||||
export const printerClient = writable<NiimbotAbstractClient>();
|
||||
|
||||
export const initClient = (connectionType: ConnectionType) => {
|
||||
printerClient.update((prevClient: NiimbotAbstractClient) => {
|
||||
let newClient: NiimbotAbstractClient = prevClient;
|
||||
|
||||
if (
|
||||
prevClient === undefined ||
|
||||
(connectionType !== "bluetooth" && prevClient instanceof NiimbotBluetoothClient) ||
|
||||
(connectionType !== "serial" && prevClient instanceof NiimbotSerialClient)
|
||||
) {
|
||||
if (prevClient !== undefined) {
|
||||
prevClient.disconnect();
|
||||
}
|
||||
if (connectionType === "bluetooth") {
|
||||
console.log("new NiimbotBluetoothClient");
|
||||
newClient = new NiimbotBluetoothClient();
|
||||
} else {
|
||||
console.log("new NiimbotSerialClient");
|
||||
newClient = new NiimbotSerialClient();
|
||||
}
|
||||
|
||||
newClient.addEventListener("rawpacketsent", (e: RawPacketSentEvent) => {
|
||||
console.log(`>> ${Utils.bufToHex(e.data)}`);
|
||||
});
|
||||
|
||||
newClient.addEventListener("rawpacketreceived", (e: RawPacketReceivedEvent) => {
|
||||
console.log(`<< ${Utils.bufToHex(e.data)}`);
|
||||
});
|
||||
|
||||
newClient.addEventListener("packetparsed", (e: PacketParsedEvent) => {
|
||||
console.log(e.packet);
|
||||
});
|
||||
|
||||
newClient.addEventListener("connect", (e: ConnectEvent) => {
|
||||
console.log("onConnect");
|
||||
connectionState.set("connected");
|
||||
connectedPrinterName.set(e.info.deviceName ?? "unknown");
|
||||
// c.sendPacket(PacketGenerator.setBtSound(false));
|
||||
});
|
||||
|
||||
newClient.addEventListener("disconnect", () => {
|
||||
console.log("onDisconnect");
|
||||
connectionState.set("disconnected");
|
||||
connectedPrinterName.set("");
|
||||
});
|
||||
}
|
||||
|
||||
return newClient;
|
||||
});
|
||||
};
|
23
niimblue/src/style.scss
Normal file
23
niimblue/src/style.scss
Normal file
@ -0,0 +1,23 @@
|
||||
@import "bootstrap/scss/bootstrap-utilities";
|
||||
@import "bootstrap/scss/reboot";
|
||||
@import "bootstrap/scss/type";
|
||||
@import "bootstrap/scss/containers";
|
||||
@import "bootstrap/scss/grid";
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/forms";
|
||||
@import "bootstrap/scss/dropdown";
|
||||
@import "bootstrap/scss/modal";
|
||||
@import "bootstrap/scss/close";
|
||||
@import "bootstrap/scss/progress";
|
||||
@import "bootstrap/scss/alert";
|
||||
|
||||
// @import "@fortawesome/fontawesome-svg-core/styles";
|
||||
|
||||
// h1.title {
|
||||
// .niim {
|
||||
// color: #ff5349;
|
||||
// }
|
||||
// .blue {
|
||||
// color: #0b7eff;
|
||||
// }
|
||||
// }
|
18
niimblue/src/types.ts
Normal file
18
niimblue/src/types.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { StartPosition } from "@mmote/niimbluelib";
|
||||
|
||||
export type ConnectionState = "connecting" | "connected" | "disconnected";
|
||||
export type ConnectionType = "bluetooth" | "serial";
|
||||
|
||||
export interface LabelProps {
|
||||
startPos: StartPosition
|
||||
size: { width: number; height: number }
|
||||
}
|
||||
export type LabelPreset = {
|
||||
width: number;
|
||||
height: number;
|
||||
unit: string;
|
||||
dpmm: number;
|
||||
startPosition: StartPosition;
|
||||
};
|
||||
|
||||
export type OjectType = "text" | "rectangle" | "line" | "circle"
|
14
niimblue/src/vite-env.d.ts
vendored
Normal file
14
niimblue/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
declare const __APP_VERSION__: string;
|
||||
declare const __BUILD_DATE__: string;
|
||||
|
||||
// not declared in ts lib, experimental feature
|
||||
declare type FontData = {
|
||||
readonly family: string;
|
||||
readonly fullName: string;
|
||||
readonly postscriptName: string;
|
||||
readonly style: string;
|
||||
};
|
||||
|
||||
declare function queryLocalFonts(): Promise<ReadonlyArray<FontData>>;
|
7
niimblue/svelte.config.js
Normal file
7
niimblue/svelte.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
}
|
13
niimblue/tsconfig.json
Normal file
13
niimblue/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES6",
|
||||
// "useDefineForClassFields": true,
|
||||
// "module": "ES6",
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": false,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||
}
|
32
niimblue/vite.config.ts
Normal file
32
niimblue/vite.config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
const getDate = (): string => {
|
||||
const date = new Date();
|
||||
const fmt = (n: number) => (n > 9 ? n : `0${n}`);
|
||||
return `${date.getFullYear()}-${fmt(date.getMonth() + 1)}-${fmt(date.getDate())}`;
|
||||
};
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
|
||||
__BUILD_DATE__: JSON.stringify(getDate()),
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
fabric: ["fabric"],
|
||||
},
|
||||
chunkFileNames: (chunkInfo) => {
|
||||
if (chunkInfo.facadeModuleId?.includes("@fortawesome")) {
|
||||
return "assets/fa-icons.[hash].js";
|
||||
}
|
||||
return "assets/[name].[hash].js";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
1428
niimblue/yarn.lock
Normal file
1428
niimblue/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
4
niimbluelib/clean-dist.js
Normal file
4
niimbluelib/clean-dist.js
Normal file
@ -0,0 +1,4 @@
|
||||
// import fs from "fs";
|
||||
const fs = require("fs");
|
||||
|
||||
fs.rmSync("dist", { recursive: true, force: true });
|
28
niimbluelib/package.json
Normal file
28
niimbluelib/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@mmote/niimbluelib",
|
||||
"version": "0.0.1",
|
||||
"description": "Library for communicating with niimbot printers",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"author": "MultiMote",
|
||||
"license": "MIT",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"clean-build": "yarn clean && yarn build",
|
||||
"build": "tsc",
|
||||
"test": "yarn build && node dist/test.js",
|
||||
"clean": "node clean-dist.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.2",
|
||||
"@types/w3c-web-serial": "^1.0.6",
|
||||
"@types/web-bluetooth": "^0.0.20",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"typescript-event-target": "^1.1.1"
|
||||
}
|
||||
}
|
119
niimbluelib/src/client/bluetooth_impl.ts
Normal file
119
niimbluelib/src/client/bluetooth_impl.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { ConnectionInfo, NiimbotAbstractClient } from ".";
|
||||
import { NiimbotPacket, PacketParser, ResponseCommandId } from "../packets";
|
||||
import { Utils } from "../utils";
|
||||
import { ConnectEvent, DisconnectEvent, PacketParsedEvent, PacketReceivedEvent, RawPacketReceivedEvent, RawPacketSentEvent } from "./events";
|
||||
|
||||
class BleConfiguration {
|
||||
public static readonly SERVICE: string = "e7810a71-73ae-499d-8c15-faa9aef0c3f2";
|
||||
public static readonly CHARACTERISTIC: string = "bef8d6c9-9c21-4c9e-b632-bd58c1009f9f";
|
||||
public static readonly FILTER: BluetoothLEScanFilter[] = [
|
||||
{ namePrefix: "D" },
|
||||
{ namePrefix: "B" },
|
||||
{ services: [BleConfiguration.SERVICE] },
|
||||
];
|
||||
}
|
||||
|
||||
export class NiimbotBluetoothClient extends NiimbotAbstractClient {
|
||||
private gattServer?: BluetoothRemoteGATTServer = undefined;
|
||||
private channel?: BluetoothRemoteGATTCharacteristic = undefined;
|
||||
|
||||
public async connect(): Promise<ConnectionInfo> {
|
||||
this.disconnect();
|
||||
|
||||
const options: RequestDeviceOptions = {
|
||||
filters: BleConfiguration.FILTER,
|
||||
};
|
||||
const device: BluetoothDevice = await navigator.bluetooth.requestDevice(options);
|
||||
|
||||
if (device.gatt === undefined) {
|
||||
throw new Error("Device has no Bluetooth Generic Attribute Profile");
|
||||
}
|
||||
|
||||
const disconnectListener = async () => {
|
||||
this.gattServer = undefined;
|
||||
this.channel = undefined;
|
||||
this.dispatchTypedEvent("disconnect", new DisconnectEvent());
|
||||
device.removeEventListener("gattserverdisconnected", disconnectListener);
|
||||
};
|
||||
|
||||
device.addEventListener("gattserverdisconnected", disconnectListener);
|
||||
|
||||
const gattServer: BluetoothRemoteGATTServer = await device.gatt.connect();
|
||||
|
||||
const service: BluetoothRemoteGATTService = await gattServer.getPrimaryService(BleConfiguration.SERVICE);
|
||||
|
||||
const channel: BluetoothRemoteGATTCharacteristic = await service.getCharacteristic(BleConfiguration.CHARACTERISTIC);
|
||||
|
||||
channel.addEventListener("characteristicvaluechanged", async (event: Event) => {
|
||||
const target = event.target as BluetoothRemoteGATTCharacteristic;
|
||||
const packet = NiimbotPacket.fromBytes(target.value!);
|
||||
|
||||
this.dispatchTypedEvent("rawpacketreceived", new RawPacketReceivedEvent(target.value!));
|
||||
this.dispatchTypedEvent("packetreceived", new PacketReceivedEvent(packet));
|
||||
|
||||
|
||||
if (!(packet.getCommand() in ResponseCommandId)) {
|
||||
console.warn(`Unknown response command: 0x${Utils.numberToHex(packet.getCommand())}`);
|
||||
} else {
|
||||
const data = PacketParser.parse(packet);
|
||||
if (data !== undefined) {
|
||||
this.dispatchTypedEvent("packetparsed", new PacketParsedEvent(data));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await channel.startNotifications();
|
||||
|
||||
this.gattServer = gattServer;
|
||||
this.channel = channel;
|
||||
|
||||
const result: ConnectionInfo = {
|
||||
deviceName: device.name,
|
||||
};
|
||||
|
||||
this.dispatchTypedEvent("connect", new ConnectEvent(result));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async disconnect() {
|
||||
this.gattServer?.disconnect();
|
||||
this.gattServer = undefined;
|
||||
this.channel = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send packet and wait for response.
|
||||
* If packet.responsePacketCommandId is defined, it will wait for packet with this command id.
|
||||
*/
|
||||
public async sendPacketWaitResponse(packet: NiimbotPacket, timeoutMs?: number): Promise<NiimbotPacket> {
|
||||
await this.sendPacket(packet);
|
||||
// what if response received at this point?
|
||||
return new Promise((resolve) => {
|
||||
let timeout: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
const listener = (evt: PacketReceivedEvent) => {
|
||||
if (packet.getValidResponseIds().length === 0 || packet.getValidResponseIds().includes(evt.packet.getCommand())) {
|
||||
clearTimeout(timeout);
|
||||
this.removeEventListener("packetreceived", listener)
|
||||
resolve(evt.packet);
|
||||
}
|
||||
};
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
this.removeEventListener("packetreceived", listener)
|
||||
throw new Error("Timeout waiting response");
|
||||
}, timeoutMs ?? 1000);
|
||||
|
||||
this.addEventListener("packetreceived", listener)
|
||||
});
|
||||
}
|
||||
|
||||
public async sendRaw(data: Uint8Array) {
|
||||
if (this.channel === undefined) {
|
||||
throw new Error("Channel is closed");
|
||||
}
|
||||
await this.channel.writeValueWithoutResponse(data);
|
||||
this.dispatchTypedEvent("rawpacketsent", new RawPacketSentEvent(data));
|
||||
}
|
||||
}
|
78
niimbluelib/src/client/events.ts
Normal file
78
niimbluelib/src/client/events.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { NiimbotPacket, ParsedPacket } from "../packets";
|
||||
|
||||
export type ConnectionInfo = {
|
||||
deviceName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export class ConnectEvent extends Event {
|
||||
info: ConnectionInfo;
|
||||
constructor(info: ConnectionInfo) {
|
||||
super("connect");
|
||||
this.info = info;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export class DisconnectEvent extends Event {
|
||||
constructor() {
|
||||
super("disconnect");
|
||||
}
|
||||
} // add reason maybe?
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export class PacketParsedEvent extends Event {
|
||||
packet: ParsedPacket;
|
||||
constructor(packet: ParsedPacket) {
|
||||
super("packetparsed");
|
||||
this.packet = packet;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export class PacketReceivedEvent extends Event {
|
||||
packet: NiimbotPacket;
|
||||
constructor(packet: NiimbotPacket) {
|
||||
super("packetreceived");
|
||||
this.packet = packet;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export class RawPacketSentEvent extends Event {
|
||||
data: Uint8Array;
|
||||
constructor(data: Uint8Array) {
|
||||
super("rawpacketsent");
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export class RawPacketReceivedEvent extends Event {
|
||||
data: DataView;
|
||||
constructor(data: DataView) {
|
||||
super("rawpacketreceived");
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClientEventMap {
|
||||
connect: ConnectEvent;
|
||||
disconnect: DisconnectEvent;
|
||||
rawpacketsent: RawPacketSentEvent;
|
||||
rawpacketreceived: RawPacketReceivedEvent;
|
||||
packetreceived: PacketReceivedEvent;
|
||||
packetparsed: PacketParsedEvent;
|
||||
}
|
40
niimbluelib/src/client/index.ts
Normal file
40
niimbluelib/src/client/index.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { ParsedPacket, NiimbotPacket, PacketParser } from "../packets";
|
||||
import { TypedEventTarget } from 'typescript-event-target';
|
||||
import { ClientEventMap, ConnectionInfo } from "./events";
|
||||
|
||||
|
||||
export abstract class NiimbotAbstractClient extends TypedEventTarget<ClientEventMap> {
|
||||
/** Connect to printer port */
|
||||
public abstract connect(): Promise<ConnectionInfo>;
|
||||
|
||||
/** Disconnect from printer port */
|
||||
public abstract disconnect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Send packet and wait for response.
|
||||
* If packet.responsePacketCommandId is defined, it will wait for packet with this command id.
|
||||
*/
|
||||
public abstract sendPacketWaitResponse(packet: NiimbotPacket, timeoutMs?: number): Promise<NiimbotPacket>;
|
||||
|
||||
/** Send raw data tp printer port */
|
||||
public abstract sendRaw(data: Uint8Array): Promise<void>;
|
||||
|
||||
public async sendPacket(packet: NiimbotPacket) {
|
||||
await this.sendRaw(packet.toBytes());
|
||||
}
|
||||
|
||||
public async sendPacketWaitResponseDecoded(packet: NiimbotPacket, timeoutMs?: number): Promise<ParsedPacket> {
|
||||
const response: NiimbotPacket = await this.sendPacketWaitResponse(packet, timeoutMs);
|
||||
|
||||
// todo: prevent double parsing without duplicating sendPacketWaitResponse
|
||||
const data = PacketParser.parse(response);
|
||||
if (!data) {
|
||||
throw new Error("Decoder for this packet is not implemented or packet is invalid");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./events";
|
||||
export * from "./bluetooth_impl";
|
||||
export * from "./serial_impl";
|
145
niimbluelib/src/client/serial_impl.ts
Normal file
145
niimbluelib/src/client/serial_impl.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { ConnectionInfo, NiimbotAbstractClient } from ".";
|
||||
import { NiimbotPacket } from "../packets";
|
||||
import { Utils } from "../utils";
|
||||
import { ConnectEvent, DisconnectEvent, RawPacketSentEvent } from "./events";
|
||||
|
||||
/** WIP. Uses serial communication, Works worse than NiimbotBluetoothClient at the moment, events are not firing */
|
||||
export class NiimbotSerialClient extends NiimbotAbstractClient {
|
||||
private port?: SerialPort = undefined;
|
||||
private writer?: WritableStreamDefaultWriter<Uint8Array> = undefined;
|
||||
|
||||
public async connect(): Promise<ConnectionInfo> {
|
||||
this.disconnect();
|
||||
|
||||
const _port: SerialPort = await navigator.serial.requestPort();
|
||||
|
||||
_port.addEventListener("disconnect", () => {
|
||||
this.port = undefined;
|
||||
this.dispatchTypedEvent("disconnect", new DisconnectEvent());
|
||||
});
|
||||
|
||||
await _port.open({ baudRate: 115200 });
|
||||
|
||||
if (_port.writable === null) {
|
||||
throw new Error("Port is not writable");
|
||||
}
|
||||
|
||||
this.port = _port;
|
||||
this.writer = _port.writable.getWriter();
|
||||
const info = _port.getInfo();
|
||||
|
||||
const result: ConnectionInfo = {
|
||||
deviceName: `Serial (VID:${info.usbVendorId?.toString(16)} PID:${info.usbProductId?.toString(16)})`,
|
||||
};
|
||||
|
||||
this.dispatchTypedEvent("connect", new ConnectEvent(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
public async disconnect() {
|
||||
if (this.writer !== undefined) {
|
||||
this.writer.releaseLock();
|
||||
}
|
||||
|
||||
if (this.port !== undefined) {
|
||||
this.port.close();
|
||||
this.dispatchTypedEvent("disconnect", new DisconnectEvent());
|
||||
}
|
||||
|
||||
this.port = undefined;
|
||||
this.writer = undefined;
|
||||
}
|
||||
|
||||
public async sendPacketWaitResponse(packet: NiimbotPacket, timeoutMs: number = 1000): Promise<NiimbotPacket> {
|
||||
if (this.port === undefined) {
|
||||
throw new Error("Port is closed");
|
||||
}
|
||||
|
||||
this.sendPacket(packet);
|
||||
|
||||
if (this.port.readable === null) {
|
||||
throw new Error("Port is not readable");
|
||||
}
|
||||
|
||||
let data = new Uint8Array();
|
||||
let p: NiimbotPacket | undefined = undefined;
|
||||
|
||||
const reader = this.port.readable.getReader();
|
||||
|
||||
// todo: rewrite, no timeout!
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
console.log("done");
|
||||
break;
|
||||
}
|
||||
const newArr = new Uint8Array(data.length + value.length);
|
||||
newArr.set(data);
|
||||
newArr.set(value, data.length);
|
||||
data = newArr;
|
||||
|
||||
try {
|
||||
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
p = NiimbotPacket.fromBytes(dv);
|
||||
break;
|
||||
} catch (e) {
|
||||
console.log("skipping");
|
||||
}
|
||||
// Do something with |value|...
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
console.log("end");
|
||||
|
||||
if (p === undefined) {
|
||||
throw new Error("err");
|
||||
}
|
||||
// const reader: ReadableStreamDefaultReader<Uint8Array> = this.port.readable.getReader();
|
||||
|
||||
// const timer: NodeJS.Timeout = setTimeout(() => {
|
||||
// reader.releaseLock();
|
||||
// }, timeoutMs);
|
||||
|
||||
// const result: ReadableStreamReadResult<Uint8Array> = await reader.read();
|
||||
|
||||
// clearTimeout(timer);
|
||||
// reader.releaseLock();
|
||||
|
||||
// const arr: Uint8Array = result.value!;
|
||||
// console.log(Utils.bufToHex(arr));
|
||||
// const dv = new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
|
||||
// const inPacket = NiimbotPacket.fromBytes(dv);
|
||||
|
||||
// this.port.readable.pipeTo(new WritableStream({
|
||||
// write(chunk) {
|
||||
// console.log("Chunk received", chunk);
|
||||
// },
|
||||
// close() {
|
||||
// console.log("All data successfully read!");
|
||||
// },
|
||||
// abort(e) {
|
||||
// console.error("Something went wrong!", e);
|
||||
// }
|
||||
// }));
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
public async sendRaw(data: Uint8Array) {
|
||||
if (this.writer === undefined) {
|
||||
throw new Error("Port is not writable");
|
||||
}
|
||||
|
||||
await this.writer.write(data);
|
||||
|
||||
// this.writer.releaseLock();
|
||||
|
||||
this.dispatchTypedEvent("rawpacketsent", new RawPacketSentEvent(data));
|
||||
await Utils.sleep(2); // fixme maybe
|
||||
}
|
||||
}
|
116
niimbluelib/src/image_encoder.ts
Normal file
116
niimbluelib/src/image_encoder.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { Utils } from "./utils";
|
||||
|
||||
export type ImageRow = {
|
||||
dataType: "void" | "pixels";
|
||||
rowNumber: number;
|
||||
repeat: number;
|
||||
blackPixelsCount: number;
|
||||
rowData?: Uint8Array;
|
||||
};
|
||||
|
||||
export type EncodedImage = {
|
||||
cols: number;
|
||||
rows: number;
|
||||
rowsData: ImageRow[];
|
||||
};
|
||||
|
||||
export type HeadPosition = "left" | "top";
|
||||
|
||||
export class ImageEncoder {
|
||||
/** headPos = "left" rotates image for 90 degrees clockwise */
|
||||
public static encodeCanvas(canvas: HTMLCanvasElement, headPos: HeadPosition = "left"): EncodedImage {
|
||||
const ctx: CanvasRenderingContext2D = canvas.getContext("2d")!;
|
||||
const iData: ImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const rowsData: ImageRow[] = [];
|
||||
|
||||
let cols: number = canvas.width;
|
||||
let rows: number = canvas.height;
|
||||
|
||||
if (headPos === "left") {
|
||||
cols = canvas.height;
|
||||
rows = canvas.width;
|
||||
}
|
||||
|
||||
if (cols % 8 !== 0) {
|
||||
throw new Error("Column count must be multiple of 8");
|
||||
}
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
let isVoid: boolean = true;
|
||||
let blackPixelsCount: number = 0;
|
||||
const rowData = new Uint8Array(cols / 8);
|
||||
|
||||
for (let colOct = 0; colOct < cols / 8; colOct++) {
|
||||
let pixelsOctet: number = 0;
|
||||
for (let colBit = 0; colBit < 8; colBit++) {
|
||||
if (ImageEncoder.isPixelNonWhite(iData, colOct * 8 + colBit, row, headPos)) {
|
||||
pixelsOctet |= 1 << (7 - colBit);
|
||||
isVoid = false;
|
||||
blackPixelsCount++;
|
||||
}
|
||||
}
|
||||
rowData[colOct] = pixelsOctet;
|
||||
}
|
||||
|
||||
const newPart: ImageRow = {
|
||||
dataType: isVoid ? "void" : "pixels",
|
||||
rowNumber: row,
|
||||
repeat: 1,
|
||||
rowData: isVoid ? undefined : rowData,
|
||||
blackPixelsCount,
|
||||
};
|
||||
|
||||
// Check previous row and increment repeats instead of adding new row if data is same
|
||||
if (rowsData.length === 0) {
|
||||
rowsData.push(newPart);
|
||||
} else {
|
||||
const lastPacket: ImageRow = rowsData[rowsData.length - 1];
|
||||
let same: boolean = newPart.dataType === lastPacket.dataType;
|
||||
|
||||
if (same && newPart.dataType === "pixels") {
|
||||
same = Utils.u8ArraysEqual(newPart.rowData!, lastPacket.rowData!);
|
||||
}
|
||||
|
||||
if (same) {
|
||||
lastPacket.repeat++;
|
||||
} else {
|
||||
rowsData.push(newPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { cols, rows, rowsData };
|
||||
}
|
||||
|
||||
/** headPos = "left" rotates image to 90 degrees clockwise */
|
||||
public static isPixelNonWhite(iData: ImageData, x: number, y: number, headPos: HeadPosition = "left"): boolean {
|
||||
let idx = y * iData.width + x;
|
||||
|
||||
if (headPos === "left") {
|
||||
idx = (iData.height - 1 - x) * iData.width + y;
|
||||
}
|
||||
|
||||
idx *= 4;
|
||||
return iData.data[idx] !== 255 || iData.data[idx + 1] !== 255 || iData.data[idx + 2] !== 255;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param data Encoded pixels (every byte is 8 pixels)
|
||||
* @returns Array of indexes where every index stored in two bytes (big endian)
|
||||
*/
|
||||
public static indexPixels(data: Uint8Array): Uint8Array {
|
||||
const result: number[] = [];
|
||||
|
||||
for (let bytePos = 0; bytePos < data.byteLength; bytePos++) {
|
||||
let b: number = data[bytePos];
|
||||
for (let bitPos = 0; bitPos < 8; bitPos++) {
|
||||
// iterate from most significant bit of byte
|
||||
if (b & (1 << (7 - bitPos))) {
|
||||
result.push(...Utils.u16ToBytes(bytePos * 8 + bitPos));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
}
|
4
niimbluelib/src/index.ts
Normal file
4
niimbluelib/src/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./client";
|
||||
export * from "./packets";
|
||||
export * from "./image_encoder";
|
||||
export * from "./utils";
|
4
niimbluelib/src/packets/index.ts
Normal file
4
niimbluelib/src/packets/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./types";
|
||||
export * from "./parser";
|
||||
export * from "./packet";
|
||||
export * from "./packet_generator";
|
109
niimbluelib/src/packets/packet.ts
Normal file
109
niimbluelib/src/packets/packet.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { Validators } from "../utils";
|
||||
import { RequestCommandId, ResponseCommandId } from "./types";
|
||||
|
||||
export class NiimbotPacket {
|
||||
public static readonly HEAD = new Uint8Array([0x55, 0x55]);
|
||||
public static readonly TAIL = new Uint8Array([0xaa, 0xaa]);
|
||||
|
||||
private commandId: RequestCommandId;
|
||||
private data: Uint8Array;
|
||||
private validResponseIds: ResponseCommandId[];
|
||||
|
||||
constructor(commandId: number, data: Uint8Array | number[], validResponseIds: ResponseCommandId[] = []) {
|
||||
this.commandId = commandId;
|
||||
this.data = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
this.validResponseIds = validResponseIds;
|
||||
}
|
||||
|
||||
public getValidResponseIds(): ResponseCommandId[] {
|
||||
return this.validResponseIds;
|
||||
}
|
||||
|
||||
public getCommand(): number {
|
||||
return this.commandId;
|
||||
}
|
||||
|
||||
public getData(): Uint8Array {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
public checksum(): number {
|
||||
let checksum = 0;
|
||||
checksum ^= this.commandId;
|
||||
checksum ^= this.data.length;
|
||||
this.data.forEach((i: number) => (checksum ^= i));
|
||||
return checksum;
|
||||
}
|
||||
|
||||
// [0x55, 0x55, CMD, DATA_LEN, DA =//= TA, CHECKSUM, 0xAA, 0xAA]
|
||||
public toBytes(): Uint8Array {
|
||||
const buf = new ArrayBuffer(
|
||||
NiimbotPacket.HEAD.length + // head
|
||||
1 + // cmd
|
||||
1 + // dataLength
|
||||
this.data.length +
|
||||
1 + // checksum
|
||||
NiimbotPacket.TAIL.length
|
||||
);
|
||||
|
||||
const arr = new Uint8Array(buf);
|
||||
|
||||
let pos = 0;
|
||||
|
||||
arr.set(NiimbotPacket.HEAD, pos);
|
||||
pos += NiimbotPacket.HEAD.length;
|
||||
|
||||
arr[pos] = this.commandId;
|
||||
pos += 1;
|
||||
|
||||
arr[pos] = this.data.length;
|
||||
pos += 1;
|
||||
|
||||
arr.set(this.data, pos);
|
||||
pos += this.data.length;
|
||||
|
||||
arr[pos] = this.checksum();
|
||||
pos += 1;
|
||||
|
||||
arr.set(NiimbotPacket.TAIL, pos);
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
public static fromBytes(buf: DataView): NiimbotPacket {
|
||||
const arrBuf: ArrayBuffer = buf.buffer;
|
||||
const head = new Uint8Array(arrBuf.slice(0, 2));
|
||||
const tail = new Uint8Array(arrBuf.slice(arrBuf.byteLength - 2));
|
||||
const minPacketSize =
|
||||
NiimbotPacket.HEAD.length + // head
|
||||
1 + // cmd
|
||||
1 + // dataLength
|
||||
1 + // checksum
|
||||
NiimbotPacket.TAIL.length;
|
||||
|
||||
if (arrBuf.byteLength < minPacketSize) {
|
||||
throw new Error(`Packet is too small (${arrBuf.byteLength} < ${minPacketSize})`);
|
||||
}
|
||||
|
||||
Validators.u8ArraysEqual(head, NiimbotPacket.HEAD, "Invalid packet head");
|
||||
|
||||
Validators.u8ArraysEqual(tail, NiimbotPacket.TAIL, "Invalid packet tail");
|
||||
|
||||
const cmd: number = buf.getUint8(2);
|
||||
const dataLen: number = buf.getUint8(3);
|
||||
|
||||
if (arrBuf.byteLength !== minPacketSize + dataLen) {
|
||||
throw new Error(`Invalid packet size (${arrBuf.byteLength} < ${minPacketSize + dataLen})`);
|
||||
}
|
||||
|
||||
const data: Uint8Array = new Uint8Array(arrBuf.slice(4, 4 + dataLen));
|
||||
const checksum: number = buf.getUint8(4 + dataLen);
|
||||
const packet = new NiimbotPacket(cmd, data);
|
||||
|
||||
if (packet.checksum() !== checksum) {
|
||||
throw new Error("Invalid packet checksum");
|
||||
}
|
||||
|
||||
return packet;
|
||||
}
|
||||
}
|
335
niimbluelib/src/packets/packet_generator.ts
Normal file
335
niimbluelib/src/packets/packet_generator.ts
Normal file
@ -0,0 +1,335 @@
|
||||
import {
|
||||
AutoShutdownTime,
|
||||
HeartbeatType,
|
||||
LabelType,
|
||||
NiimbotPacket,
|
||||
PrinterInfoType,
|
||||
RequestCommandId,
|
||||
ResponseCommandId,
|
||||
SoundSettingsItemType,
|
||||
SoundSettingsType,
|
||||
} from ".";
|
||||
import { EncodedImage, ImageEncoder, ImageRow as ImagePart } from "../image_encoder";
|
||||
import { Utils } from "../utils";
|
||||
|
||||
export type PrintOptions = {
|
||||
labelType?: LabelType;
|
||||
density?: number;
|
||||
quantity?: number;
|
||||
};
|
||||
|
||||
export enum PrintSequenceVariant {
|
||||
/** Used in D110 */
|
||||
V1 = 1,
|
||||
/** Used in B1 */
|
||||
V2,
|
||||
}
|
||||
|
||||
export class PacketGenerator {
|
||||
public static generic(
|
||||
requestId: RequestCommandId,
|
||||
data: Uint8Array | number[],
|
||||
responseIds: ResponseCommandId[] = []
|
||||
): NiimbotPacket {
|
||||
return new NiimbotPacket(requestId, data, responseIds);
|
||||
}
|
||||
|
||||
public static rfidInfo(): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.RfidInfo, [1], [ResponseCommandId.In_RfidInfo]);
|
||||
}
|
||||
|
||||
public static setAutoShutDownTime(time: AutoShutdownTime): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.SetAutoShutdownTime, [time], [ResponseCommandId.In_SetAutoShutdownTime]);
|
||||
}
|
||||
|
||||
public static getPrinterInfo(type: PrinterInfoType): NiimbotPacket {
|
||||
return new NiimbotPacket(
|
||||
RequestCommandId.PrinterInfo,
|
||||
[type],
|
||||
[
|
||||
ResponseCommandId.In_PrinterInfoDensity,
|
||||
// ResponseCommandId.In_PrinterInfoSpeed,
|
||||
ResponseCommandId.In_PrinterInfoLabelType,
|
||||
// ResponseCommandId.In_PrinterInfoLanguage,
|
||||
ResponseCommandId.In_PrinterInfoAutoShutDownTime,
|
||||
ResponseCommandId.In_PrinterInfoPrinterCode,
|
||||
ResponseCommandId.In_PrinterInfoSoftWareVersion,
|
||||
ResponseCommandId.In_PrinterInfoElectricity,
|
||||
ResponseCommandId.In_PrinterInfoSerialNumber,
|
||||
ResponseCommandId.In_PrinterInfoHardWareVersion,
|
||||
ResponseCommandId.In_PrinterInfoBluetoothAddress,
|
||||
// ResponseCommandId.In_PrinterInfoPrintMode,
|
||||
ResponseCommandId.In_PrinterInfoUnknown1,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public static setBtSound(on: boolean): NiimbotPacket {
|
||||
return new NiimbotPacket(
|
||||
RequestCommandId.SoundSettings,
|
||||
[SoundSettingsType.SetSound, SoundSettingsItemType.BluetoothConnectionSound, on ? 1 : 0],
|
||||
[ResponseCommandId.In_SoundSettings]
|
||||
);
|
||||
}
|
||||
|
||||
public static getBtSoundEnabled(): NiimbotPacket {
|
||||
return new NiimbotPacket(
|
||||
RequestCommandId.SoundSettings,
|
||||
[SoundSettingsType.GetSoundState, SoundSettingsItemType.BluetoothConnectionSound, 1],
|
||||
[ResponseCommandId.In_SoundSettings]
|
||||
);
|
||||
}
|
||||
|
||||
public static setPowerSound(on: boolean): NiimbotPacket {
|
||||
return new NiimbotPacket(
|
||||
RequestCommandId.SoundSettings,
|
||||
[SoundSettingsType.SetSound, SoundSettingsItemType.PowerSound, on ? 1 : 0],
|
||||
[ResponseCommandId.In_SoundSettings]
|
||||
);
|
||||
}
|
||||
|
||||
public static getPowerSoundEnabled(): NiimbotPacket {
|
||||
return new NiimbotPacket(
|
||||
RequestCommandId.SoundSettings,
|
||||
[SoundSettingsType.GetSoundState, SoundSettingsItemType.PowerSound, 1],
|
||||
[ResponseCommandId.In_SoundSettings]
|
||||
);
|
||||
}
|
||||
|
||||
public static heartbeat(type: HeartbeatType): NiimbotPacket {
|
||||
return new NiimbotPacket(
|
||||
RequestCommandId.Heartbeat,
|
||||
[type],
|
||||
[
|
||||
ResponseCommandId.In_Heartbeat1,
|
||||
ResponseCommandId.In_Heartbeat2,
|
||||
ResponseCommandId.In_Heartbeat3,
|
||||
ResponseCommandId.In_Heartbeat4,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public static setDensity(value: number): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.SetDensity, [value]);
|
||||
}
|
||||
|
||||
public static setLabelType(value: number): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.SetLabelType, [value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B1 behavior: strange, first print is blank or printer prints many copies (use {@link setPageSizeV2} instead)
|
||||
*
|
||||
* D110 behavior: ordinary.
|
||||
*
|
||||
* @param rows Height in pixels
|
||||
* @param cols Width in pixels
|
||||
*/
|
||||
public static setPageSize(rows: number, cols: number): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.SetPageSize, [...Utils.u16ToBytes(rows), ...Utils.u16ToBytes(cols)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rows Height in pixels
|
||||
* @param cols Width in pixels
|
||||
* @param copiesCount Page instances
|
||||
*/
|
||||
public static setPageSizeV2(rows: number, cols: number, copiesCount: number): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.SetPageSize, [
|
||||
...Utils.u16ToBytes(rows),
|
||||
...Utils.u16ToBytes(cols),
|
||||
...Utils.u16ToBytes(copiesCount),
|
||||
]);
|
||||
}
|
||||
|
||||
public static setPrintQuantity(quantity: number): NiimbotPacket {
|
||||
const [h, l] = Utils.u16ToBytes(quantity);
|
||||
return new NiimbotPacket(RequestCommandId.PrintQuantity, [h, l]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B1 behavior: after {@link pageEnd} paper stops at printhead position, on {@link printEnd} paper moved further.
|
||||
*
|
||||
* D110 behavior: ordinary.
|
||||
* */
|
||||
public static printStart(): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.PrintStart, [1]);
|
||||
}
|
||||
|
||||
// 5555 01 07 -- 00 01 00 00 00 00 00 -- 07aaaa
|
||||
/**
|
||||
* B1 behavior: when {@link totalPages} > 1 after {@link pageEnd} paper stops at printhead position and waits for next page.
|
||||
* When last page ({@link totalPages}) printed paper moved further.
|
||||
*
|
||||
* D110 behavior: ordinary.
|
||||
*
|
||||
* @param totalPages Declare how many pages will be printed
|
||||
*/
|
||||
public static printStartV2(totalPages: number): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.PrintStart, [
|
||||
...Utils.u16ToBytes(totalPages),
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
}
|
||||
|
||||
public static printEnd(): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.PrintEnd, [1]);
|
||||
}
|
||||
public static pageStart(): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.PageStart, [1]);
|
||||
}
|
||||
public static pageEnd(): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.PageEnd, [1]);
|
||||
}
|
||||
|
||||
// https://github.com/ayufan/niimprint-web/blob/main/cmds.js#L215
|
||||
public static printEmptySpace(pos: number, repeats: number): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.PrintEmptyRow, [...Utils.u16ToBytes(pos), repeats]);
|
||||
}
|
||||
|
||||
public static printBitmapRow(pos: number, repeats: number, data: Uint8Array): NiimbotPacket {
|
||||
const blackPixelCount: number = Utils.countSetBits(data);
|
||||
|
||||
return new NiimbotPacket(RequestCommandId.PrintBitmapRow, [
|
||||
...Utils.u16ToBytes(pos),
|
||||
// Black pixel count. Not sure what role it plays in printing.
|
||||
// There is two formats of this part
|
||||
// 1. <count> <count> <count> (sum must equals number of pixels, every number calculated by algorithm based on printhead resolution)
|
||||
// 2. <0> <countH> <countL> (big endian)
|
||||
0,
|
||||
...Utils.u16ToBytes(blackPixelCount),
|
||||
repeats,
|
||||
...data,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Printer powers off if black pixel count > 6 */
|
||||
public static printBitmapRowIndexed(pos: number, repeats: number, data: Uint8Array): NiimbotPacket {
|
||||
const blackPixelCount: number = Utils.countSetBits(data);
|
||||
const indexes: Uint8Array = ImageEncoder.indexPixels(data);
|
||||
|
||||
if (blackPixelCount > 6) {
|
||||
throw new Error(`Black pixel count > 6 (${blackPixelCount})`);
|
||||
}
|
||||
|
||||
return new NiimbotPacket(RequestCommandId.PrintBitmapRowIndexed, [
|
||||
...Utils.u16ToBytes(pos),
|
||||
0,
|
||||
...Utils.u16ToBytes(blackPixelCount),
|
||||
repeats,
|
||||
...indexes,
|
||||
]);
|
||||
}
|
||||
|
||||
public static printClear(): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.PrintClear, [1]);
|
||||
}
|
||||
|
||||
public static writeRfid(data: Uint8Array): NiimbotPacket {
|
||||
return new NiimbotPacket(RequestCommandId.WriteRFID, data);
|
||||
}
|
||||
|
||||
public static writeImageData(image: EncodedImage): NiimbotPacket[] {
|
||||
return image.rowsData.map((p: ImagePart) => {
|
||||
if (p.dataType === "pixels") {
|
||||
if (p.blackPixelsCount > 6) {
|
||||
return PacketGenerator.printBitmapRow(p.rowNumber, p.repeat, p.rowData!);
|
||||
} else {
|
||||
return PacketGenerator.printBitmapRowIndexed(p.rowNumber, p.repeat, p.rowData!);
|
||||
}
|
||||
} else {
|
||||
return PacketGenerator.printEmptySpace(p.rowNumber, p.repeat);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
D100 print process example (square, 240x96)
|
||||
|
||||
SetLabelType 55 55 23 01 01 23 aa aa
|
||||
SetDensity 55 55 21 01 03 23 aa aa
|
||||
PrintStart 55 55 01 01 01 01 aa aa
|
||||
PrintClear 55 55 20 01 01 20 aa aa
|
||||
PageStart 55 55 03 01 01 03 aa aa
|
||||
SetPageSize 55 55 13 04 00 f0 00 60 87 aa aa
|
||||
PrintQuantity 55 55 15 02 00 01 16 aa aa
|
||||
PrintEmptySpace 55 55 84 03 00 00 52 d5 aa aa
|
||||
PrintBitmapRows 55 55 85 12 00 52 18 20 18 04 00 ff ff ff ff ff ff ff ff ff ff 00 e1 aa aa
|
||||
PrintBitmapRows 55 55 85 12 00 56 04 00 04 48 00 f0 00 00 00 00 00 00 00 00 0f 00 76 aa aa
|
||||
PrintBitmapRows 55 55 85 12 00 9e 18 20 18 04 00 ff ff ff ff ff ff ff ff ff ff 00 2d aa aa
|
||||
PrintEmptySpace 55 55 84 03 00 a2 26 03 aa aa
|
||||
PrintEmptySpace 55 55 84 03 00 c8 28 67 aa aa
|
||||
PageEnd 55 55 e3 01 01 e3 aa aa
|
||||
PrintStatus 55 55 a3 01 01 a3 aa aa (alot)
|
||||
PrintEnd 55 55 f3 01 01 f3 aa aa
|
||||
|
||||
|
||||
You should send PrintEnd manually after this sequence (after print finished)
|
||||
*/
|
||||
public static generatePrintSequenceV1(image: EncodedImage, options?: PrintOptions): NiimbotPacket[] {
|
||||
return [
|
||||
PacketGenerator.setLabelType(options?.labelType ?? LabelType.Separated),
|
||||
PacketGenerator.setDensity(options?.density ?? 2),
|
||||
PacketGenerator.printStart(),
|
||||
PacketGenerator.printClear(),
|
||||
PacketGenerator.pageStart(),
|
||||
PacketGenerator.setPageSize(image.rows, image.cols),
|
||||
PacketGenerator.setPrintQuantity(options?.quantity ?? 1),
|
||||
...PacketGenerator.writeImageData(image),
|
||||
PacketGenerator.pageEnd(),
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
B1 print process example (square in square, 160x240)
|
||||
|
||||
SetDensity 5555 21 0102 22aaaa
|
||||
SetLabelType 5555 23 0101 23aaaa
|
||||
PrintStart 5555 01 0700010000000000 07aaaa
|
||||
PageStart 5555 03 0101 03aaaa
|
||||
SetPageSize 5555 13 0600a000f00001 44aaaa
|
||||
PrintEmptyRows 5555 84 0300001d 9aaaaa
|
||||
PrintBitmapRows 5555 85 24 001d 3e3000 04 000000000000003ffffffffffffffffffffffffffffffff0000000000000 86aaaa
|
||||
PrintBitmapRows 5555 85 24 0021 b83000 21 000000000000003c000000000000000000000000000000f0000000000000 e5aaaa
|
||||
PrintBitmapRows 5555 85 24 0042 9e3000 04 000000000000003c000000000007fffffe000000000000f0000000000000 7caaaa
|
||||
PrintBitmapRows 5555 85 24 0046 b03000 14 000000000000003c00000000000780001e000000000000f0000000000000 26aaaa
|
||||
PrintBitmapRows 5555 85 24 005a 9e3000 04 000000000000003c000000000007fffffe000000000000f0000000000000 64aaaa
|
||||
PrintBitmapRows 5555 85 24 005e b83000 23 000000000000003c000000000000000000000000000000f0000000000000 98aaaa
|
||||
PrintBitmapRows 5555 85 24 0081 3e3000 04 000000000000003ffffffffffffffffffffffffffffffff0000000000000 1aaaaa
|
||||
PrintEmptyRows 5555 84 0300851b19aaaa
|
||||
PageEnd 5555 e3 0101 e3aaaa
|
||||
PrintStatus 5555 a3 0101 a3aaaa (alot)
|
||||
PrintEnd 5555 f3 0101 f3aaaa
|
||||
|
||||
|
||||
You should send PrintEnd manually after this sequence (after print finished)
|
||||
*/
|
||||
public static generatePrintSequenceV2(image: EncodedImage, options?: PrintOptions): NiimbotPacket[] {
|
||||
return [
|
||||
PacketGenerator.setDensity(options?.density ?? 2),
|
||||
PacketGenerator.setLabelType(options?.labelType ?? LabelType.Separated),
|
||||
PacketGenerator.printStartV2(options?.quantity ?? 1),
|
||||
PacketGenerator.pageStart(),
|
||||
PacketGenerator.setPageSizeV2(image.rows, image.cols, options?.quantity ?? 1),
|
||||
...PacketGenerator.writeImageData(image),
|
||||
PacketGenerator.pageEnd(),
|
||||
];
|
||||
}
|
||||
|
||||
public static generatePrintSequence(
|
||||
variant: PrintSequenceVariant,
|
||||
image: EncodedImage,
|
||||
options?: PrintOptions
|
||||
): NiimbotPacket[] {
|
||||
switch (variant) {
|
||||
case PrintSequenceVariant.V1:
|
||||
return PacketGenerator.generatePrintSequenceV1(image, options);
|
||||
case PrintSequenceVariant.V2:
|
||||
return PacketGenerator.generatePrintSequenceV2(image, options);
|
||||
}
|
||||
}
|
||||
}
|
70
niimbluelib/src/packets/parser/data_reader.ts
Normal file
70
niimbluelib/src/packets/parser/data_reader.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { Utils } from "../..";
|
||||
|
||||
/** Utility class to sequentially fetch data from byte array. EOF checks included. */
|
||||
export class SequentialDataReader {
|
||||
private bytes: Uint8Array;
|
||||
private offset: number;
|
||||
|
||||
constructor(bytes: Uint8Array) {
|
||||
this.bytes = bytes;
|
||||
this.offset = 0;
|
||||
}
|
||||
|
||||
/** Check available bytes bytes and throw exception if EOF met */
|
||||
private willRead(count: number) {
|
||||
// console.log(`willRead ${count} (offset becomes ${this.offset+count} / ${this.bytes.length})`)
|
||||
if (this.offset + count > this.bytes.length) {
|
||||
throw new Error("Tried to read too much data");
|
||||
}
|
||||
}
|
||||
|
||||
/** Skip bytes */
|
||||
skip(len: number): void {
|
||||
this.willRead(len);
|
||||
this.offset += len;
|
||||
}
|
||||
|
||||
/** Read fixed length bytes */
|
||||
readBytes(len: number): Uint8Array {
|
||||
this.willRead(len);
|
||||
const part = this.bytes.slice(this.offset, this.offset + len);
|
||||
this.offset += len;
|
||||
return part;
|
||||
}
|
||||
|
||||
/** Read variable length bytes */
|
||||
readVBytes(): Uint8Array {
|
||||
let len = this.readI8();
|
||||
let part: Uint8Array = this.readBytes(len);
|
||||
return part;
|
||||
}
|
||||
|
||||
/** Read variable length string */
|
||||
readVString(): string {
|
||||
let part: Uint8Array = this.readVBytes();
|
||||
return Utils.u8ArrayToString(part);
|
||||
}
|
||||
|
||||
/** Read 8 bit int (big endian) */
|
||||
readI8(): number {
|
||||
this.willRead(1);
|
||||
const result = this.bytes[this.offset];
|
||||
this.offset += 1;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Read 16 bit int (big endian) */
|
||||
readI16(): number {
|
||||
this.willRead(2);
|
||||
const part = this.bytes.slice(this.offset, this.offset + 2);
|
||||
this.offset += 2;
|
||||
return Utils.bytesToI16(part);
|
||||
}
|
||||
|
||||
/** Check EOF condition */
|
||||
end() {
|
||||
if (this.offset != this.bytes.length) {
|
||||
throw new Error("Extra data left");
|
||||
}
|
||||
}
|
||||
}
|
2
niimbluelib/src/packets/parser/index.ts
Normal file
2
niimbluelib/src/packets/parser/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./data_reader"
|
||||
export * from "./parsed_packets"
|
191
niimbluelib/src/packets/parser/parsed_packets.ts
Normal file
191
niimbluelib/src/packets/parser/parsed_packets.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { LabelType, NiimbotPacket, ResponseCommandId, SoundSettingsItemType, SoundSettingsType } from "..";
|
||||
import { Utils, Validators } from "../..";
|
||||
import { SequentialDataReader } from "../parser/data_reader";
|
||||
|
||||
export interface ParsedPacket {
|
||||
commandId: ResponseCommandId;
|
||||
}
|
||||
|
||||
export interface SettingsInDecoded extends ParsedPacket {
|
||||
category: SoundSettingsType;
|
||||
item: SoundSettingsItemType;
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
export interface PrinterInfoSerialDecoded extends ParsedPacket {
|
||||
// infoType: PrinterInfoType
|
||||
serialNumber: string;
|
||||
}
|
||||
|
||||
export interface PrinterInfoLabelTypeDecoded extends ParsedPacket {
|
||||
labelType: number;
|
||||
}
|
||||
|
||||
export interface RfidInfoDecoded extends ParsedPacket {
|
||||
tagPresent: boolean;
|
||||
uuid: string;
|
||||
barCode: string;
|
||||
serialNumber: string;
|
||||
allPaper: number;
|
||||
usedPaper: number;
|
||||
consumablesType: LabelType;
|
||||
}
|
||||
|
||||
export interface PrinterInfoPrinterCodeDecoded extends ParsedPacket {
|
||||
/** See {@link PrinterId} */
|
||||
code: number;
|
||||
}
|
||||
|
||||
/** closingState inverted on some printers */
|
||||
export interface HeartbeatDecoded extends ParsedPacket {
|
||||
paperState: number;
|
||||
rfidReadState: number;
|
||||
closingState: number;
|
||||
powerLevel: number;
|
||||
}
|
||||
|
||||
export class PacketParser {
|
||||
private static decodePrinterInfoSerialPacket(packet: NiimbotPacket): PrinterInfoSerialDecoded {
|
||||
return {
|
||||
commandId: packet.getCommand(),
|
||||
serialNumber: Utils.u8ArrayToString(packet.getData()),
|
||||
};
|
||||
}
|
||||
|
||||
private static decodeSettingsPacket(packet: NiimbotPacket): SettingsInDecoded {
|
||||
Validators.u8ArrayLengthEqual(packet.getData(), 3);
|
||||
|
||||
return {
|
||||
commandId: packet.getCommand(),
|
||||
category: packet.getData()[0],
|
||||
item: packet.getData()[1],
|
||||
value: !!packet.getData()[2],
|
||||
};
|
||||
}
|
||||
|
||||
private static decodePrinterInfoLabelTypePacket(packet: NiimbotPacket): PrinterInfoLabelTypeDecoded {
|
||||
Validators.u8ArrayLengthEqual(packet.getData(), 1);
|
||||
|
||||
return {
|
||||
commandId: packet.getCommand(),
|
||||
labelType: packet.getData()[0],
|
||||
};
|
||||
}
|
||||
|
||||
private static decodePrinterInfoPrinterCodePacket(packet: NiimbotPacket): PrinterInfoPrinterCodeDecoded {
|
||||
Validators.u8ArrayLengthEqual(packet.getData(), 2);
|
||||
|
||||
return {
|
||||
commandId: packet.getCommand(),
|
||||
code: Utils.bytesToI16(packet.getData()),
|
||||
};
|
||||
}
|
||||
|
||||
// 55 55 1b 27 -- 88 1d ab 43 d3 8c 00 00 -- 08 - 30 32 32 38 32 32 38 30 -- 10 - 50 5a 31 47 33 31 31 33 33 30 30 30 30 33 38 35 -- 00 fc -- 00 38 -- 01 -- b2 aa aa
|
||||
private static decodeRfidInfo(packet: NiimbotPacket): RfidInfoDecoded {
|
||||
const info: RfidInfoDecoded = {
|
||||
commandId: packet.getCommand(),
|
||||
tagPresent: false,
|
||||
uuid: "",
|
||||
barCode: "",
|
||||
serialNumber: "",
|
||||
allPaper: -1,
|
||||
usedPaper: -1,
|
||||
consumablesType: LabelType.Invalid,
|
||||
};
|
||||
|
||||
if (packet.getData().length === 1) {
|
||||
return info;
|
||||
}
|
||||
|
||||
const r = new SequentialDataReader(packet.getData());
|
||||
info.tagPresent = true;
|
||||
info.uuid = Utils.bufToHex(r.readBytes(8), "");
|
||||
info.barCode = r.readVString();
|
||||
info.serialNumber = r.readVString();
|
||||
info.allPaper = r.readI16();
|
||||
info.usedPaper = r.readI16();
|
||||
info.consumablesType = r.readI8() as LabelType;
|
||||
r.end();
|
||||
return info;
|
||||
}
|
||||
|
||||
// 55 55 dd 0a 10 23 00 69 00 69 32 4f 01 04 9c aa aa
|
||||
// 55 55 dd 0a 10 23 00 52 00 52 32 4f 00 04 9d aa aa
|
||||
private static decodeHeartbeat(packet: NiimbotPacket): HeartbeatDecoded {
|
||||
const info: HeartbeatDecoded = {
|
||||
commandId: packet.getCommand(),
|
||||
paperState: -1,
|
||||
rfidReadState: -1,
|
||||
closingState: -1,
|
||||
powerLevel: -1,
|
||||
};
|
||||
|
||||
// originally expected packet length is calculated from model id, but we make it simple
|
||||
const len = packet.getData().length;
|
||||
const r = new SequentialDataReader(packet.getData());
|
||||
|
||||
if (len === 17 - 7) {
|
||||
// d110
|
||||
// n3 = n8 + 9;
|
||||
// DataCheck.parseClosingState(callback, byArray[n3], n2);
|
||||
// DataCheck.parsePowerLevel(callback, byArray[n8 + 10]);
|
||||
// DataCheck.parseRfidReadState(callback, byArray[n3], n2); ??????
|
||||
r.skip(8);
|
||||
info.closingState = r.readI8();
|
||||
info.powerLevel = r.readI8();
|
||||
} else if (len === 27 - 7) {
|
||||
// 19 parsePaperState
|
||||
// 20 parseRfidReadState
|
||||
r.skip(18);
|
||||
info.paperState = r.readI8();
|
||||
info.rfidReadState = r.readI8();
|
||||
} else if (len === 26 - 7) {
|
||||
// 16 parseClosingState
|
||||
// 17 parsePowerLevel
|
||||
// 18 parsePaperState
|
||||
// 19 parseRfidReadState
|
||||
r.skip(15);
|
||||
info.closingState = r.readI8();
|
||||
info.powerLevel = r.readI8();
|
||||
info.paperState = r.readI8();
|
||||
info.rfidReadState = r.readI8();
|
||||
} else if (len === 20 - 7) {
|
||||
// b1
|
||||
r.skip(9);
|
||||
info.closingState = r.readI8();
|
||||
info.powerLevel = r.readI8();
|
||||
info.paperState = r.readI8();
|
||||
info.rfidReadState = r.readI8();
|
||||
// 10 parseClosingState
|
||||
// 11 parsePowerLevel
|
||||
// 12 parsePaperState
|
||||
// 13 parseRfidReadState
|
||||
} else {
|
||||
throw new Error("Invalid heartbeat length");
|
||||
}
|
||||
r.end();
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
public static parse(packet: NiimbotPacket): ParsedPacket | undefined {
|
||||
const command: ResponseCommandId = packet.getCommand();
|
||||
|
||||
switch (command) {
|
||||
case ResponseCommandId.In_SoundSettings:
|
||||
return this.decodeSettingsPacket(packet);
|
||||
case ResponseCommandId.In_PrinterInfoSerialNumber:
|
||||
return this.decodePrinterInfoSerialPacket(packet);
|
||||
case ResponseCommandId.In_PrinterInfoLabelType:
|
||||
return this.decodePrinterInfoLabelTypePacket(packet);
|
||||
case ResponseCommandId.In_RfidInfo:
|
||||
return this.decodeRfidInfo(packet);
|
||||
case ResponseCommandId.In_PrinterInfoPrinterCode:
|
||||
return this.decodePrinterInfoPrinterCodePacket(packet);
|
||||
case ResponseCommandId.In_Heartbeat1:
|
||||
return this.decodeHeartbeat(packet);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
188
niimbluelib/src/packets/types.ts
Normal file
188
niimbluelib/src/packets/types.ts
Normal file
@ -0,0 +1,188 @@
|
||||
export enum RequestCommandId {
|
||||
/** see {@link AutoShutdownTime} */
|
||||
CancelPrint = 0xda,
|
||||
Heartbeat = 0xdc,
|
||||
LabelPositioningCalibration = 0x8e, //-114,
|
||||
PageEnd = 0xe3,
|
||||
PageNumber = 0x05,
|
||||
PageStart = 0x03,
|
||||
PrintBitmapRow = 0x85, // -123
|
||||
PrintBitmapRowIndexed = 0x83, // -125, indexed if black pixels < 6
|
||||
PrintClear = 0x20,
|
||||
PrintEmptyRow = 0x84, // -124
|
||||
PrintEnd = 0xf3,
|
||||
PrinterInfo = 0x40, // See PrinterInfoType
|
||||
PrinterStatusData = 0xa5,
|
||||
PrintQuantity = 0x15,
|
||||
PrintStart = 0x01,
|
||||
PrintStartV3 = 0x02,
|
||||
PrintStatus = 0xa3,
|
||||
RfidInfo = 0x1a,
|
||||
RfidInfo2 = 0x1c,
|
||||
RfidSuccessTimes = 0x54,
|
||||
SetAutoShutdownTime = 0x27, ///
|
||||
SetDensity = 0x21,
|
||||
SetLabelType = 0x23 /* D11 - 1,5, for D110 able to set 1,2,3,5; see LabelType */,
|
||||
SetPageSize = 0x13, // 2, 4 or 6 bytes
|
||||
SoundSettings = 0x58,
|
||||
Unknown1 = 0x0b, // some info request (niimbot app), 01 long 02 short
|
||||
WriteRFID = 0x70,
|
||||
}
|
||||
|
||||
export enum ResponseCommandId {
|
||||
In_Error = 0x00,
|
||||
In_Heartbeat1 = 0xdd,
|
||||
In_Heartbeat2 = 0xdf,
|
||||
In_Heartbeat3 = 0xde,
|
||||
In_Heartbeat4 = 0xd9,
|
||||
In_PageEnd = 0xe3,
|
||||
In_PageStart = 0x04,
|
||||
In_PrintClear = 0x30,
|
||||
In_PrintEmptyRows = 0xd3,
|
||||
In_PrintEnd = 0xf4,
|
||||
In_PrinterInfoAutoShutDownTime = 0x47,
|
||||
In_PrinterInfoBluetoothAddress = 0x4d,
|
||||
In_PrinterInfoDensity = 0x41,
|
||||
In_PrinterInfoElectricity = 0x4a,
|
||||
In_PrinterInfoHardWareVersion = 0x4c,
|
||||
In_PrinterInfoLabelType = 0x43,
|
||||
In_PrinterInfoPrinterCode = 0x48,
|
||||
In_PrinterInfoSerialNumber = 0x4b,
|
||||
In_PrinterInfoSoftWareVersion = 0x49,
|
||||
In_PrinterInfoUnknown1 = 0x4f,
|
||||
IN_PrinterStatusData = 0xb5,
|
||||
In_PrintParamsError = 0xdb, // For example, sent on SetPageSize when page print is not started
|
||||
In_PrintQuantity = 0x16,
|
||||
In_PrintStart = 0x02,
|
||||
In_RfidInfo = 0x1b,
|
||||
IN_RfidSuccessTimes = 0x64,
|
||||
In_SetAutoShutdownTime = 0x37,
|
||||
In_SetDensity = 0x31,
|
||||
In_SetLabelType = 0x33,
|
||||
In_SetPageSize = 0x14,
|
||||
In_SoundSettings = 0x68,
|
||||
In_Unknown1 = 0xe4,
|
||||
In_Unknown2 = 0xc2,
|
||||
}
|
||||
|
||||
export enum PrinterInfoType {
|
||||
Density = 1,
|
||||
Speed = 2,
|
||||
LabelType = 3,
|
||||
Language = 6,
|
||||
AutoShutDownTime = 7,
|
||||
/** See {@link PrinterId} */
|
||||
PrinterCode = 8,
|
||||
SoftWareVersion = 9,
|
||||
Electricity = 10,
|
||||
SerialNumber = 11,
|
||||
HardWareVersion = 12,
|
||||
BluetoothAddress = 13,
|
||||
PrintMode = 14,
|
||||
Unknown1 = 15,
|
||||
}
|
||||
|
||||
export enum SoundSettingsType {
|
||||
SetSound = 0x01,
|
||||
GetSoundState = 0x02,
|
||||
}
|
||||
|
||||
export enum SoundSettingsItemType {
|
||||
BluetoothConnectionSound = 0x01,
|
||||
PowerSound = 0x02,
|
||||
}
|
||||
|
||||
// https://github.com/dadrum/niimbot_flutter_plugin/blob/main/lib/niimbot/niimbot_platform_interface/niimbot.dart#L167
|
||||
export enum LabelType {
|
||||
Invalid = 0,
|
||||
/** Default for D11 and similar */
|
||||
Separated = 1,
|
||||
WithBlackMarkers = 2,
|
||||
Continuous = 3,
|
||||
Perforated = 4,
|
||||
Transparent = 5,
|
||||
}
|
||||
|
||||
export enum HeartbeatType {
|
||||
Unknown1 = 1,
|
||||
Unknown2 = 2,
|
||||
Unknown3 = 3,
|
||||
Unknown4 = 4,
|
||||
}
|
||||
|
||||
export enum AutoShutdownTime {
|
||||
Shutdown15min = 1,
|
||||
Shutdown30min = 2,
|
||||
Shutdown45min = 3,
|
||||
Shutdown60min = 4,
|
||||
}
|
||||
|
||||
/** Battery charge level */
|
||||
export enum PowerLevel {
|
||||
Power0 = 0,
|
||||
Power25 = 1,
|
||||
Power50 = 2,
|
||||
Power75 = 3,
|
||||
Power100 = 4,
|
||||
}
|
||||
|
||||
/** Generated from android app (assets/flutter_assets/assets/config/printerList.json) */
|
||||
export enum PrinterId {
|
||||
UNKNOWN = 0,
|
||||
T6 = 51715,
|
||||
TP2M_H = 4609,
|
||||
B31 = 5632,
|
||||
B1 = 4096,
|
||||
M2_H = 4608,
|
||||
B21_PRO = 785,
|
||||
P1 = 1024,
|
||||
T2S = 53250,
|
||||
B50W = 51714,
|
||||
T7 = 51717,
|
||||
B50 = 51713,
|
||||
Z401 = 2051,
|
||||
B32R = 2050,
|
||||
A63 = 2054,
|
||||
T8S = 2053,
|
||||
B32 = 2049,
|
||||
B18S = 3585,
|
||||
B18 = 3584,
|
||||
MP3K_W = 4867,
|
||||
MP3K = 4866,
|
||||
K3_W = 4865,
|
||||
K3 = 4864,
|
||||
B3S_P = 272,
|
||||
S6 = 261,
|
||||
B3S = 256,
|
||||
B3S_V2 = 260,
|
||||
B3S_V3 = 262,
|
||||
B3 = 52993,
|
||||
A203 = 2818,
|
||||
A20 = 2817,
|
||||
B203 = 2816,
|
||||
S1 = 51458,
|
||||
JC_M90 = 51461,
|
||||
S3 = 51460,
|
||||
B11 = 51457,
|
||||
B21_H = 784,
|
||||
B21S_C2B = 776,
|
||||
B21S = 777,
|
||||
B21_L2B = 769,
|
||||
B21_C2B = 771,
|
||||
B21_C2B_V2 = 775,
|
||||
B21 = 768,
|
||||
B16 = 1792,
|
||||
BETTY = 2561,
|
||||
D101 = 2560,
|
||||
D110_M = 2320,
|
||||
HI_D110 = 2305,
|
||||
D110 = 2304,
|
||||
D11_H = 528,
|
||||
D11S = 514,
|
||||
FUST = 513,
|
||||
D11 = 512,
|
||||
ET10 = 5376,
|
||||
P18 = 1026,
|
||||
P1S = 1025,
|
||||
T8 = 51718
|
||||
}
|
24
niimbluelib/src/test.ts
Normal file
24
niimbluelib/src/test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { ImageEncoder, Utils } from ".";
|
||||
|
||||
// const data = Uint8Array.of(0xF0, 0x10);
|
||||
// const result = ImageEncoder.indexPixels(data);
|
||||
|
||||
// console.log(Utils.bufToHex(result));
|
||||
// // 00 00 00 0E
|
||||
|
||||
|
||||
// const canvas = createCanvas(16, 8);
|
||||
// const ctx = canvas.getContext("2d");
|
||||
// ctx.fillStyle = "white";
|
||||
// ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// ctx.fillStyle = "black";
|
||||
// ctx.fillRect(0, 0, 1, 1);
|
||||
// ctx.fillRect(canvas.width-1, 0, 1, 1);
|
||||
// ctx.fillRect(1, 1, 3, 1);
|
||||
// ctx.fillRect(1, 3, 1, 1);
|
||||
|
||||
// const data = ImageEncoder.encodeCanvas(canvas, "top");
|
||||
// data.rowsData.forEach(e => console.log(e))
|
||||
// console.log("")
|
||||
// ImageEncoder.encodeCanvasV2(canvas, "top");
|
88
niimbluelib/src/utils.ts
Normal file
88
niimbluelib/src/utils.ts
Normal file
@ -0,0 +1,88 @@
|
||||
export class Utils {
|
||||
public static numberToHex(n: number): string {
|
||||
const hex = n.toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
}
|
||||
|
||||
public static bufToHex(buf: DataView | Uint8Array, separator: string = " "): string {
|
||||
const arr: number[] = buf instanceof DataView ? this.dataViewToNumberArray(buf) : Array.from(buf);
|
||||
return arr.map(Utils.numberToHex).join(separator);
|
||||
}
|
||||
|
||||
public static dataViewToNumberArray(dw: DataView): number[] {
|
||||
const a: number[] = [];
|
||||
for (let i = 0; i < dw.byteLength; i++) {
|
||||
a.push(dw.getUint8(i));
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
public static dataViewToU8Array(dw: DataView): Uint8Array {
|
||||
return Uint8Array.from(this.dataViewToNumberArray(dw));
|
||||
}
|
||||
|
||||
public static u8ArrayToString(arr: Uint8Array): string {
|
||||
return new TextDecoder().decode(arr);
|
||||
}
|
||||
|
||||
/** Count non-zero bits in the byte array */
|
||||
public static countSetBits(arr: Uint8Array): number {
|
||||
// not so efficient, but readable
|
||||
let count: number = 0;
|
||||
|
||||
arr.forEach((value) => {
|
||||
// shift until value becomes zero
|
||||
while (value > 0) {
|
||||
// check last bit
|
||||
if ((value & 1) === 1) {
|
||||
count++;
|
||||
}
|
||||
value >>= 1;
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Big endian */
|
||||
public static u16ToBytes(n: number): [number, number] {
|
||||
const h = (n >> 8) & 0xff;
|
||||
const l = n % 256 & 0xff;
|
||||
return [h, l];
|
||||
}
|
||||
|
||||
/** Big endian */
|
||||
public static bytesToI16(arr: Uint8Array): number {
|
||||
Validators.u8ArrayLengthEqual(arr, 2);
|
||||
return arr[0] * 256 + arr[1];
|
||||
}
|
||||
|
||||
public static u8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
return a.length === b.length && a.every((el, i) => el === b[i]);
|
||||
}
|
||||
|
||||
public static sleep(ms: number): Promise<undefined> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
public static isBluetoothSupported(): boolean {
|
||||
return typeof navigator.bluetooth?.requestDevice !== "undefined";
|
||||
}
|
||||
|
||||
public static isSerialSupported(): boolean {
|
||||
return typeof navigator.serial?.requestPort !== "undefined";
|
||||
}
|
||||
}
|
||||
|
||||
export class Validators {
|
||||
public static u8ArraysEqual(a: Uint8Array, b: Uint8Array, message?: string): void {
|
||||
if (!Utils.u8ArraysEqual(a, b)) {
|
||||
throw new Error(message ?? "Arrays must be equal");
|
||||
}
|
||||
}
|
||||
public static u8ArrayLengthEqual(a: Uint8Array, len: number, message?: string): void {
|
||||
if (a.length !== len) {
|
||||
throw new Error(message ?? `Array length must be ${len}`);
|
||||
}
|
||||
}
|
||||
}
|
14
niimbluelib/tsconfig.json
Normal file
14
niimbluelib/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"include": ["src/**/*"],
|
||||
"compilerOptions": {
|
||||
"target": "ES6", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
"lib": ["ES6", "DOM"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
"outDir": "./dist", /* Specify an output folder for all emitted files. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
35
niimbluelib/yarn.lock
Normal file
35
niimbluelib/yarn.lock
Normal file
@ -0,0 +1,35 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@types/node@^20.14.2":
|
||||
version "20.14.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.2.tgz#a5f4d2bcb4b6a87bffcaa717718c5a0f208f4a18"
|
||||
integrity sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==
|
||||
dependencies:
|
||||
undici-types "~5.26.4"
|
||||
|
||||
"@types/w3c-web-serial@^1.0.6":
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/w3c-web-serial/-/w3c-web-serial-1.0.6.tgz#f064dbad4f3019326060df2a24f4c61ee787edb2"
|
||||
integrity sha512-5IlDdQ2C56sCVwc7CUlqT9Axxw+0V/FbWRbErklYIzZ5mKL9s4l7epXHygn+4X7L2nmAPnVvRl55XUVo0760Rg==
|
||||
|
||||
"@types/web-bluetooth@^0.0.20":
|
||||
version "0.0.20"
|
||||
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597"
|
||||
integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==
|
||||
|
||||
typescript-event-target@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/typescript-event-target/-/typescript-event-target-1.1.1.tgz#20a6d491b77d2e37dc432c5394ab74c0d7065539"
|
||||
integrity sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg==
|
||||
|
||||
typescript@^5.4.5:
|
||||
version "5.4.5"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611"
|
||||
integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==
|
||||
|
||||
undici-types@~5.26.4:
|
||||
version "5.26.5"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
|
||||
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
|
Reference in New Issue
Block a user