This commit is contained in:
MultiMote 2025-02-16 11:05:23 +03:00
commit d97f211ddc
22 changed files with 2264 additions and 0 deletions

8
.dockerignore Normal file

@ -0,0 +1,8 @@
*
!/src
!/package-lock.json
!/package.json
!/svelte.config.js
!/vite.config.ts
!/tsconfig.json
!/.npmrc

23
.gitignore vendored Normal file

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file

@ -0,0 +1 @@
engine-strict=true

20
.vscode/launch.json vendored Normal file

@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node-terminal",
"name": "Run Script: dev",
"request": "launch",
"command": "npm run dev",
"cwd": "${workspaceFolder}",
"env": {
"TSHARK_PATH": "C:\\Program Files\\Wireshark\\tshark.exe",
"TMP_DIR": "tmp"
}
}
]
}

25
Dockerfile Normal file

@ -0,0 +1,25 @@
FROM node:22-alpine AS builder
ENV PUBLIC_BASE_URL=https://dev.mmote.ru/niim-parser/
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM node:22-alpine
WORKDIR /app
RUN apk add tshark && apk cache clean
COPY --from=builder /app/package.json /app/package-lock.json ./
COPY --from=builder /app/build ./sv-server
RUN npm ci --omit dev
EXPOSE 3000
CMD ["node", "sv-server"]

3
README.md Normal file

@ -0,0 +1,3 @@
# Dump viewer
![](./preview.png)

1694
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file

@ -0,0 +1,28 @@
{
"name": "niim-parser",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^5.4.11"
},
"dependencies": {
"@mmote/niimbluelib": "0.0.1-alpha.23",
"svelte-file-dropzone": "^2.0.9",
"uuid": "^11.0.5"
}
}

BIN
preview.png Normal file

Binary file not shown.

After

(image error) Size: 95 KiB

20
src/app.css Normal file

@ -0,0 +1,20 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
}
body {
font-family: Arial, Helvetica, sans-serif;
background-color: #303138;
color: #d6d6d6;
}
.dropzone {
background-color: #262626 !important;
}

13
src/app.d.ts vendored Normal file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

13
src/app.html Normal file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NIIMBOT dump parser</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

1
src/lib/index.ts Normal file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

105
src/lib/utils.ts Normal file

@ -0,0 +1,105 @@
import child_process from "child_process";
interface TsharkJson {
_source: {
layers: {
"frame.time_relative": string[];
"usb.capdata"?: string[];
"usb.dst"?: string[];
"hci_h4.direction"?: string[];
"btspp.data"?: string[];
"usbcom.data.in_payload"?: string[];
"usbcom.data.out_payload"?: string[];
};
};
}
export interface TsharkJsonNormalized {
time: number;
proto: "usb" | "btspp";
direction: ">>" | "<<";
data: string;
}
export const runTshark = async (path: string): Promise<TsharkJsonNormalized[]> => {
const tshark = process.env.TSHARK_PATH || "tshark";
return new Promise<TsharkJsonNormalized[]>((resolve, reject) => {
const args = [
"-2",
"-r",
path,
"-P",
"-T",
"json",
"-e",
"frame.time_relative",
"-e",
"usb.capdata",
"-e",
"btspp.data",
"-e",
"usb.dst",
"-e",
"hci_h4.direction",
"-e",
"usbcom.data.in_payload",
"-e",
"usbcom.data.out_payload",
"-R",
"usb.capdata || btspp.data || usbcom.data.in_payload || usbcom.data.out_payload",
];
const result = child_process.spawnSync(tshark, args);
if (result.error) {
reject(result.error);
return;
}
if (result.status !== 0) {
reject(new Error(result.stderr?.toString()));
return;
}
const raw = JSON.parse(result.stdout?.toString()) as TsharkJson[];
const normalized: TsharkJsonNormalized[] = raw.map((e) => {
if (e._source.layers["btspp.data"] !== undefined && e._source.layers["hci_h4.direction"] !== undefined) {
return {
time: parseFloat(e._source.layers["frame.time_relative"][0]),
proto: "usb",
direction: e._source.layers["hci_h4.direction"][0] === "0x01" ? "<<" : ">>",
data: e._source.layers["btspp.data"][0],
};
}
if (e._source.layers["usbcom.data.in_payload"] !== undefined) {
return {
time: parseFloat(e._source.layers["frame.time_relative"][0]),
proto: "usb",
direction: "<<",
data: e._source.layers["usbcom.data.in_payload"][0],
};
}
if (e._source.layers["usbcom.data.out_payload"] !== undefined) {
return {
time: parseFloat(e._source.layers["frame.time_relative"][0]),
proto: "usb",
direction: ">>",
data: e._source.layers["usbcom.data.out_payload"][0],
};
}
if (e._source.layers["usb.capdata"] !== undefined && e._source.layers["usb.dst"] !== undefined) {
return {
time: parseFloat(e._source.layers["frame.time_relative"][0]),
proto: "usb",
direction: e._source.layers["usb.dst"][0] === "host" ? "<<" : ">>",
data: e._source.layers["usb.capdata"][0],
};
}
throw new Error("Invalid frame");
});
resolve(normalized);
});
};

@ -0,0 +1,5 @@
<script>
import "../app.css";
</script>
<slot />

220
src/routes/+page.svelte Normal file

@ -0,0 +1,220 @@
<script lang="ts">
import { PUBLIC_BASE_URL } from "$env/static/public";
import type { TsharkJsonNormalized } from "$lib/utils";
import { Utils, PacketParser, RequestCommandId, ResponseCommandId } from "@mmote/niimbluelib";
import Dropzone from "svelte-file-dropzone";
let data: TsharkJsonNormalized[] = [];
let error: string = "";
let uploading: boolean = false;
let rx: boolean = true;
let tx: boolean = true;
let showInfo: boolean = true;
let showTime: boolean = true;
let allPacketTypes: { rx: string[]; tx: string[] } = {
tx: Object.values(RequestCommandId)
.filter((v) => typeof v === "string")
.sort(),
rx: Object.values(ResponseCommandId)
.filter((v) => typeof v === "string")
.sort(),
};
let disabledPacketTypes: string[] = [];
const switchPacket = (p: string) => {
if (disabledPacketTypes.includes(p)) {
disabledPacketTypes = disabledPacketTypes.filter((e) => e !== p);
} else {
disabledPacketTypes = [...disabledPacketTypes, p];
}
};
const handleFilesSelect = async (e: CustomEvent) => {
const { acceptedFiles }: { acceptedFiles: File[] } = e.detail;
error = "";
uploading = true;
if (acceptedFiles.length === 1) {
const form = new FormData();
form.append("file", acceptedFiles[0]);
const resp = await fetch(PUBLIC_BASE_URL + "api/upload", {
method: "POST",
body: form,
});
const json = await resp.json();
if (resp.ok) {
data = json as TsharkJsonNormalized[];
} else if ("error" in json) {
error = json.error;
}
}
uploading = false;
};
const formatHex = (hex: string) => {
return hex
.toUpperCase()
.replace(/^(03)?(5555)([A-F0-9]{2})([A-F0-9]{2})(.*?)([A-F0-9]{2})(AAAA)$/, "$1 $2 $3 $4 $5 $6 $7");
};
const formatInfo = (direction: string, hex: string): { typeStr: string; msg: string } => {
try {
const infos = [];
if (hex.startsWith("035555")) hex = hex.slice(2);
const buf = Utils.hexToBuf(hex);
const packets = PacketParser.parsePacketBundle(buf);
// if(comment += ResponseCommandId[packet.command])
infos.push(
...packets.map((p) => {
let msg = "";
if (direction === ">>") {
msg = RequestCommandId[p.command];
if (p.command === RequestCommandId.SetPageSize && p.dataLength >= 4) {
const cols = Utils.bytesToI16(p.data.slice(0, 2));
const rows = Utils.bytesToI16(p.data.slice(2, 4));
msg += ` (${rows}x${cols}px, ${rows / 8}x${cols / 8}mm 203dpi)`;
}
} else {
msg = ResponseCommandId[p.command];
}
return msg;
})
);
return { typeStr: infos.join(", "), msg: infos.join(", ") || "???" };
} catch (e) {
return { typeStr: "Invalid", msg: "❌" };
}
};
</script>
<div class="container">
<Dropzone disabled={uploading} multiple={false} on:drop={handleFilesSelect}>
<p>Drag USB/BLE capture file here (.pcap/.log/etc.)</p>
</Dropzone>
{#if uploading}Uploading...{/if}
{#if error}
<div class="error">{error}</div>
{/if}
<div class="filters">
<input type="checkbox" bind:checked={rx} id="rx" />
<label for="rx">rx</label>
<input type="checkbox" bind:checked={tx} id="tx" />
<label for="tx">tx</label>
<input type="checkbox" bind:checked={showTime} id="time" />
<label for="time">show time</label>
<input type="checkbox" bind:checked={showInfo} id="info" />
<label for="info">show info</label>
</div>
<div class="filters">
{#each allPacketTypes.tx as t}
<button class="parsed tx pill {disabledPacketTypes.includes(t) && 'disabled'}" on:click={() => switchPacket(t)}
>{t}</button
>
{/each}
</div>
<div class="filters">
{#each allPacketTypes.rx as t}
<button class="parsed rx pill {disabledPacketTypes.includes(t) && 'disabled'}" on:click={() => switchPacket(t)}
>{t}</button
>
{/each}
</div>
<div class="data">
{#each data as d}
{@const info = formatInfo(d.direction, d.data)}
{#if ((d.direction === "<<" && rx) || (d.direction === ">>" && tx)) && !disabledPacketTypes.includes(info.typeStr)}
<div class="row">
{#if showTime}
<span class="time pill">{d.time.toFixed(3)}</span>
{/if}
<span class="{d.direction == '<<' ? 'rx' : 'tx'} pill">{d.direction}</span>
<span class="hex pill">{formatHex(d.data)}</span>
{#if showInfo}
<span class="parsed pill {d.direction == '<<' ? 'rx' : 'tx'}">{info.msg}</span>
{/if}
</div>
{/if}
{/each}
</div>
</div>
<style>
.container {
margin: 16px 64px;
}
.error {
color: red;
margin-top: 1em;
}
.filters {
margin-top: 1em;
}
.pill {
padding: 0 0.3em;
border-radius: 4px;
border: 1px solid gray;
margin: 0 0.1em;
color: #d6d6d6;
}
.data {
margin-top: 32px;
font-family: monospace;
max-width: 100%;
}
.data .row {
padding-bottom: 12px;
}
.pill.rx {
background-color: #0b9c0b;
border-color: #064d06;
}
.pill.tx {
background-color: #bd440d;
border-color: #863109;
}
.pill.parsed.tx {
background-color: rgba(189, 69, 13, 0.22);
}
.pill.parsed.rx {
background-color: rgba(11, 156, 11, 0.22);
}
.pill.parsed.tx.disabled {
color: #757575;
border-color: #491b05;
background-color: rgba(189, 69, 13, 0.1);
}
.pill.parsed.rx.disabled {
color: #747474;
border-color: #042e04;
background-color: rgba(11, 156, 11, 0.1);
}
.hex {
overflow-wrap: break-word;
}
</style>

@ -0,0 +1,7 @@
import { json, type RequestHandler } from "@sveltejs/kit";
export const GET: RequestHandler = async ({ url }) => {
return json({ });
};

@ -0,0 +1,26 @@
import { json, type RequestHandler } from "@sveltejs/kit";
import { writeFile, unlink } from "node:fs/promises";
import { v4 as uuidv4 } from "uuid";
import { runTshark } from "$lib/utils";
export const POST: RequestHandler = async ({ request }) => {
const formData = await request.formData();
const file = formData.get("file");
const tmp = process.env.TMP_DIR || "/tmp";
const path = `${tmp}/${uuidv4()}.bin`;
try {
if (file === null || !(file instanceof File)) {
return json({ error: "No file" }, { status: 400 });
}
await writeFile(path, Buffer.from(await file.arrayBuffer()));
const out = await runTshark(path);
return json(out);
} catch (e) {
return json({ error: `${e}` }, { status: 400 });
} finally {
await unlink(path);
}
};

BIN
static/favicon.png Normal file

Binary file not shown.

After

(image error) Size: 1.5 KiB

26
svelte.config.js Normal file

@ -0,0 +1,26 @@
import adapter from "@sveltejs/adapter-node";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
compilerOptions: {
// runes: true,
},
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
},
csrf: {
checkOrigin: false,
},
};
export default config;

20
tsconfig.json Normal file

@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"types": ["node"]
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

6
vite.config.ts Normal file

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});