Working tree changes 2024-07-19 12:44

This commit is contained in:
Bot 2024-07-19 12:44:11 +03:00 committed by multimote
commit ff18d568e6
46 changed files with 4571 additions and 0 deletions

24
niimblue/.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,4 @@
# NiimBlue

13
niimblue/index.html Normal file
View 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
View 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
View 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
View 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}

View 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
View 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;

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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>>;

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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"
}
}

View 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));
}
}

View 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;
}

View 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";

View 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
}
}

View 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
View File

@ -0,0 +1,4 @@
export * from "./client";
export * from "./packets";
export * from "./image_encoder";
export * from "./utils";

View File

@ -0,0 +1,4 @@
export * from "./types";
export * from "./parser";
export * from "./packet";
export * from "./packet_generator";

View 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;
}
}

View 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);
}
}
}

View 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");
}
}
}

View File

@ -0,0 +1,2 @@
export * from "./data_reader"
export * from "./parsed_packets"

View 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;
}
}

View 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
View 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
View 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
View 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
View 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==