From e92794eabceacbf99d06509d2aefba3c113edea6 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 14 Aug 2026 00:01:38 -0700 Subject: [PATCH] Decode raw telemetry packets --- .gitignore | 1 + docs/_javascript/telemetry_decoder.js | 287 +++++++++++++++++++++++++- docs/index.md | 2 +- docs/telemetry_decoder.md | 101 ++++----- scripts/test_telemetry_decoder.js | 124 +++++++++++ 5 files changed, 461 insertions(+), 54 deletions(-) create mode 100644 scripts/test_telemetry_decoder.js diff --git a/.gitignore b/.gitignore index 6d076440..1ac1012c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ out/ site/ # Local firmware archives and build logs out.*/ +out-*/ build-logs/ .direnv/ .DS_Store diff --git a/docs/_javascript/telemetry_decoder.js b/docs/_javascript/telemetry_decoder.js index 6696822a..91a667d1 100644 --- a/docs/_javascript/telemetry_decoder.js +++ b/docs/_javascript/telemetry_decoder.js @@ -4,10 +4,25 @@ const TYPE_TEMPERATURE = 0x11; const TYPE_VOLTAGE = 0x12; const TYPE_GPS = 0x13; + const BINARY_TEMPERATURE_MAGIC = "TTB1"; + const BINARY_VOLTAGE_MAGIC = "TVB1"; + const BINARY_HEADER_SIZE = 19; + const BINARY_MAX_SAMPLES = 165; + const PAYLOAD_TYPE_RAW_CUSTOM = 0x0f; const METERS_PER_DEGREE = 111320; const DEGREES_TO_RADIANS = Math.PI / 180; const EXAMPLES = Object.freeze({ + packetTemperature: Object.freeze({ + label: "Analyzer temperature packet", + command: "send telemetry.tx now", + reply: "3E00545442311122334455667788800092651E0008000102354A4E5082", + }), + packetVoltage: Object.freeze({ + label: "Analyzer voltage packet", + command: "send telemetry.tx now", + reply: "3E00545642311122334455667788800092651E000800010264C8FEFFDC", + }), temperature: Object.freeze({ label: "Temperature example", command: "get telemetry.temp", @@ -95,6 +110,78 @@ return bytes; } + function bytesToHex(bytes) { + return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")) + .join("") + .toUpperCase(); + } + + function asciiAt(bytes, offset, length) { + let value = ""; + for (let index = 0; index < length; index += 1) { + value += String.fromCharCode(bytes[offset + index]); + } + return value; + } + + function extractHexBytes(input) { + if (typeof input !== "string" || input.trim() === "") { + throw new TelemetryDecodeError("Paste raw packet hex from the analyzer first."); + } + if (input.length > 32768) { + throw new TelemetryDecodeError("The pasted value is too large to be a MeshCore packet."); + } + + const text = input + .trim() + .replace(/^```(?:text|json)?\s*/i, "") + .replace(/```\s*$/, ""); + const candidates = [text]; + text.split(/\r?\n/).forEach((line) => { + candidates.push(line); + const separator = line.search(/[:=]/); + if (separator >= 0) candidates.push(line.slice(separator + 1)); + }); + const streams = text.match( + /(?:0x)?[0-9a-f]{2}(?:(?:[\s:,_-]*)(?:0x)?[0-9a-f]{2}){18,}/gi + ); + if (streams) candidates.push(...streams); + + let firstValid = null; + for (const candidate of candidates) { + const normalized = candidate + .trim() + .replace(/^['"]|['",;]$/g, "") + .replace(/0x/gi, "") + .replace(/[\s:,_-]/g, ""); + if (normalized.length < BINARY_HEADER_SIZE * 2 + || normalized.length % 2 !== 0 + || !/^[0-9a-f]+$/i.test(normalized)) { + continue; + } + const bytes = new Uint8Array(normalized.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(normalized.slice(index * 2, index * 2 + 2), 16); + } + if (firstValid === null) firstValid = bytes; + for (let offset = 0; offset <= bytes.length - 4; offset += 1) { + const magic = asciiAt(bytes, offset, 4); + if (magic === BINARY_TEMPERATURE_MAGIC || magic === BINARY_VOLTAGE_MAGIC) { + return bytes; + } + } + } + + if (firstValid) { + throw new TelemetryDecodeError( + "Hex was found, but it does not contain TTB1 temperature or TVB1 voltage telemetry." + ); + } + throw new TelemetryDecodeError( + "Raw packet data must contain complete two-character hexadecimal bytes." + ); + } + function uint32LE(bytes, offset) { return ( bytes[offset] | @@ -157,6 +244,156 @@ return header.firstEpoch + index * header.intervalMinutes * 60; } + function decodePacketEnvelope(bytes, payloadOffset) { + if (bytes.length < 2) return null; + const headerByte = bytes[0]; + const routeCode = headerByte & 0x03; + const payloadType = (headerByte >> 2) & 0x0f; + const payloadVersion = (headerByte >> 6) + 1; + let pathLengthOffset = 1; + if (routeCode === 0 || routeCode === 3) pathLengthOffset += 4; + if (pathLengthOffset >= bytes.length) return null; + const pathMetadata = bytes[pathLengthOffset]; + const hashSizeCode = pathMetadata >> 6; + if (hashSizeCode === 3) return null; + const hopCount = pathMetadata & 0x3f; + const pathHashBytes = hashSizeCode + 1; + const expectedPayloadOffset = pathLengthOffset + 1 + hopCount * pathHashBytes; + if (expectedPayloadOffset !== payloadOffset || expectedPayloadOffset > bytes.length) return null; + + const routeNames = ["transport flood", "flood", "direct", "transport direct"]; + return { + headerByte, + routeCode, + routeName: routeNames[routeCode], + payloadType, + payloadVersion, + hopCount, + pathHashBytes, + payloadOffset, + isRawCustom: payloadType === PAYLOAD_TYPE_RAW_CUSTOM, + }; + } + + function decodeBinarySnapshot(bytes, offset) { + if (bytes.length - offset < BINARY_HEADER_SIZE) { + throw new TelemetryDecodeError("The binary telemetry header is incomplete."); + } + const magic = asciiAt(bytes, offset, 4); + const count = bytes[offset + 18]; + const intervalMinutes = bytes[offset + 16] | (bytes[offset + 17] << 8); + if (count < 1 || count > BINARY_MAX_SAMPLES) { + throw new TelemetryDecodeError( + `Binary telemetry sample count ${count} is outside the supported 1-${BINARY_MAX_SAMPLES} range.` + ); + } + if (intervalMinutes === 0) { + throw new TelemetryDecodeError("Binary telemetry sample interval cannot be zero."); + } + const payloadLength = BINARY_HEADER_SIZE + count; + if (bytes.length - offset < payloadLength) { + throw new TelemetryDecodeError( + `Binary telemetry declares ${count} samples but ${payloadLength - (bytes.length - offset)} payload bytes are missing.` + ); + } + + const header = { + typeCode: magic, + firstEpoch: uint32LE(bytes, offset + 12), + intervalMinutes, + count, + byteLength: payloadLength, + sourceId: bytesToHex(bytes.slice(offset + 4, offset + 12)), + format: "binary", + payloadOffset: offset, + inputByteLength: bytes.length, + trailingBytes: bytes.length - offset - payloadLength, + packet: decodePacketEnvelope(bytes, offset), + }; + const rows = []; + for (let index = 0; index < count; index += 1) { + const rawCode = bytes[offset + BINARY_HEADER_SIZE + index]; + if (magic === BINARY_TEMPERATURE_MAGIC) { + let status = "Value"; + let valueC = null; + if (rawCode === 0) status = "Missing"; + else if (rawCode === 1) status = "Below range"; + else if (rawCode === 2) status = "Above range"; + else if (rawCode <= 130) valueC = rawCode - 53; + else status = "Reserved code"; + rows.push({ + index, + epoch: sampleEpoch(header, index), + status, + statusCode: null, + rawCode, + valueC, + }); + } else { + let status = "Value"; + let millivolts = null; + if (rawCode === 0) status = "Missing"; + else if (rawCode === 1) status = "Below range"; + else if (rawCode === 255) status = "Above range"; + else millivolts = 1880 + (rawCode - 2) * 10; + rows.push({ + index, + epoch: sampleEpoch(header, index), + status, + rawCode, + millivolts, + }); + } + } + const warnings = []; + if (header.packet && !header.packet.isRawCustom) { + warnings.push( + `The enclosing packet type is 0x${header.packet.payloadType.toString(16)}, not RAW_CUSTOM (0x0f).` + ); + } + if (header.trailingBytes > 0) { + warnings.push(`${header.trailingBytes} byte(s) after the declared telemetry payload were ignored.`); + } + if (magic === BINARY_TEMPERATURE_MAGIC + && rows.some((row) => row.status === "Reserved code")) { + warnings.push("One or more temperature samples use a reserved code and may be corrupt."); + } + return { + ...header, + kind: magic === BINARY_TEMPERATURE_MAGIC ? "temperature" : "voltage", + label: magic === BINARY_TEMPERATURE_MAGIC ? "Temperature" : "Battery voltage", + rows, + warnings, + }; + } + + function decodeRawTelemetryHex(input) { + const bytes = extractHexBytes(input); + const decodedCandidates = []; + let lastError = null; + for (let offset = 0; offset <= bytes.length - 4; offset += 1) { + const magic = asciiAt(bytes, offset, 4); + if (magic !== BINARY_TEMPERATURE_MAGIC && magic !== BINARY_VOLTAGE_MAGIC) continue; + try { + decodedCandidates.push(decodeBinarySnapshot(bytes, offset)); + } catch (error) { + lastError = error; + } + } + if (decodedCandidates.length === 0) { + if (lastError) throw lastError; + throw new TelemetryDecodeError("No supported telemetry snapshot was found in the hex data."); + } + if (decodedCandidates.length > 1) { + throw new TelemetryDecodeError( + "More than one telemetry snapshot was found; paste one analyzer packet at a time." + ); + } + const decoded = decodedCandidates[0]; + decoded.encoded = bytesToHex(bytes); + return decoded; + } + function decodeTemperature(bytes, header) { const statusBytes = Math.ceil((header.count * 2) / 8); const valueBytes = Math.ceil((header.count * 7) / 8); @@ -353,6 +590,17 @@ } function decodeTelemetry(input) { + const compactHex = typeof input === "string" + ? input.replace(/^```(?:text|json)?\s*/i, "") + .replace(/```\s*$/, "") + .replace(/0x/gi, "") + .replace(/[\s:,_-]/g, "") + .replace(/^['"]|['",;]$/g, "") + : ""; + if ((/^[0-9a-f]+$/i.test(compactHex) && compactHex.length % 2 === 0) + || /54544231|54564231/i.test(String(input))) { + return decodeRawTelemetryHex(input); + } const encoded = extractBase64(input); const bytes = base64ToBytes(encoded); if (bytes.length === 0) { @@ -398,7 +646,13 @@ if (decoded.kind === "temperature") { return { - headers: ["#", timestampHeader, "Status", "Temperature", "Raw status/code"], + headers: [ + "#", + timestampHeader, + "Status", + "Temperature", + decoded.format === "binary" ? "Raw code" : "Raw status/code", + ], rows: decoded.rows.map((row) => ({ muted: row.status === "Missing", values: [ @@ -412,7 +666,7 @@ : row.valueC === null ? "—" : `${row.valueC} °C`, - `${row.statusCode} / ${row.rawCode}`, + row.statusCode === null ? row.rawCode : `${row.statusCode} / ${row.rawCode}`, ], })), }; @@ -468,13 +722,35 @@ function summaryItems(decoded, localTime) { const lastEpoch = sampleEpoch(decoded, decoded.count - 1); const items = [ - ["Payload", `${decoded.label} (0x${decoded.typeCode.toString(16)})`], + [ + "Payload", + decoded.format === "binary" + ? `${decoded.label} (${decoded.typeCode})` + : `${decoded.label} (0x${decoded.typeCode.toString(16)})`, + ], ["Samples", String(decoded.count)], ["Interval", `${decoded.intervalMinutes} minutes`], ["First sample", timestampText(decoded.firstEpoch, localTime)], ["Last sample", timestampText(lastEpoch, localTime)], ["Decoded size", `${decoded.byteLength} bytes`], ]; + if (decoded.format === "binary") { + items.splice(1, 0, ["Repeater ID", decoded.sourceId]); + items.push([ + "Input", + decoded.packet + ? `MeshCore ${decoded.packet.routeName}, ${decoded.packet.hopCount} path hops` + : decoded.payloadOffset === 0 + ? "Telemetry payload hex" + : `Embedded payload at byte ${decoded.payloadOffset}`, + ]); + if (decoded.packet) { + items.push([ + "Packet header", + `0x${decoded.packet.headerByte.toString(16).padStart(2, "0")} / payload v${decoded.packet.payloadVersion}`, + ]); + } + } if (decoded.kind === "gps") { items.push([ "Origin", @@ -624,7 +900,8 @@ const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = `meshcore-telemetry-${current.kind}.csv`; + const sourcePart = current.sourceId ? `${current.sourceId.toLowerCase()}-` : ""; + link.download = `meshcore-telemetry-${sourcePart}${current.kind}.csv`; document.body.appendChild(link); link.click(); link.remove(); @@ -642,6 +919,8 @@ EXAMPLES, TelemetryDecodeError, decodeTelemetry, + decodeRawTelemetryHex, + extractHexBytes, extractBase64, tableModel, modelToCsv, diff --git a/docs/index.md b/docs/index.md index 9d4cc13d..61edebe3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,7 +7,7 @@ Below are a few quick start guides. - [Frequently Asked Questions](./faq.md) - [CLI Commands](./cli_commands.md) - [Filter Policy Playground](./filter_tool.md) -- [Telemetry History Decoder](./telemetry_decoder.md) +- [Telemetry Decoder](./telemetry_decoder.md) - [CLI Availability by Firmware Build](./cli_build_matrix.md) - [Easy LoRa OTA: ESP32 full images and nRF52 deltas](./ota_easy.md) - [Scripted LoRa OTA: Bash and PowerShell](./lora_ota_automation.md) diff --git a/docs/telemetry_decoder.md b/docs/telemetry_decoder.md index 1145a89e..b3a8cb22 100644 --- a/docs/telemetry_decoder.md +++ b/docs/telemetry_decoder.md @@ -1,55 +1,37 @@ -# Telemetry history decoder +# Telemetry decoder -Decode the Base64 reply from MeshCore repeater telemetry commands into a -timestamped table. Decoding happens entirely in this browser; the pasted reply -is not uploaded or sent anywhere. +Paste raw hexadecimal packet data copied from the +[Let's Mesh packet analyzer](https://analyzer.letsmesh.net/packets) to decode a +repeater's scheduled temperature or battery-voltage snapshot. The decoder also +accepts the payload hex without its MeshCore packet header. Decoding happens +entirely in this browser; pasted data is not uploaded or sent anywhere. -## Commands to run +The **Repeater ID** in the result is the first eight bytes of the sending +repeater's public key. Match that 16-character hex value against the public-key +prefix recorded for your repeaters. It comes from the telemetry payload itself, +so it is available even when only the payload was copied. -Run one of these commands in a local serial CLI or a remote administrator CLI -session, then copy the complete reply beginning with `> ` into the decoder. - -| Data | Newest page | Older-page example | Samples per page | -|---|---|---|---| -| MCU temperature | `get telemetry.temp` | `get telemetry.temp 2` | 48 (24 hours) | -| Battery voltage | `get telemetry.volt` | `get telemetry.volt 3` | 48 (24 hours) | -| GPS position | `get telemetry.gps` | `get telemetry.gps 2` | 24 (12 hours) | - -Page `1` is always the newest. Temperature and voltage support pages `1`-`7`. -GPS normally supports pages `1`-`6`; its configured retention can be changed -from one through 30 days: - -```text -set telemetry.gps 7 -get telemetry.gps 1 -get telemetry.gps 14 -``` - -The GPS setter reports the number of days and pages the device could actually -allocate. Telemetry history is boot-local, so a freshly rebooted repeater may -reply that its history is empty. - -## Decode a reply +## Decode a packet
- Try an example: - - - + Try an analyzer example: + +
- +

- Paste either the complete > ... response or Base64 alone. + Spaces, line breaks, colons, dashes, a leading 0x, and a quoted + JSON field are accepted. Legacy CLI Base64 replies are also auto-detected. Press Ctrl/Command+Enter to decode.

@@ -80,37 +62,53 @@ reply that its history is empty.
-## Example calls and replies +## Analyzer hex examples -The buttons above load these synthetic but protocol-valid examples. Real -responses have the same `> ` prefix and are auto-detected from payload type -`0x11`, `0x12`, or `0x13`. +The buttons load synthetic, protocol-valid zero-hop RAW_CUSTOM packets. A real +scheduled snapshot normally has 165 samples and is much longer. Routed packets +also contain path bytes before the `TTB1` or `TVB1` payload magic; the decoder +finds and validates the payload automatically. ### Temperature ```text -get telemetry.temp -> EUDUcWoeMAVZVVVVUVVVVVXVVQACTJlSwEyZMmTJkuXKkyZEeNFARIcOFChQoUOHDiRY0aPI/ypUuXMmTA== +3E00545442311122334455667788800092651E0008000102354A4E5082 ``` ### Battery voltage +```text +3E00545642311122334455667788800092651E000800010264C8FEFFDC +``` + +Both examples identify the source as repeater ID `1122334455667788`. + +## Legacy CLI replies + +The same page continues to decode the padded Base64 returned by these +administrator commands: + +| Data | Newest page | Older-page example | Samples per page | +|---|---|---|---| +| MCU temperature | `get telemetry.temp` | `get telemetry.temp 2` | 48 (24 hours) | +| Battery voltage | `get telemetry.volt` | `get telemetry.volt 3` | 48 (24 hours) | +| GPS position | `get telemetry.gps` | `get telemetry.gps 2` | 24 (12 hours) | + +Paste either the complete reply beginning with `> ` or Base64 alone. For +example: + ```text get telemetry.volt 1 > EkDUcWoeMAAB5+bl5eTj4uLh4ODf3t7d3Nvb2tnZ2NfX1tXU1NPS0tHQ0M/Ozc3My8vKycnI/w== ``` -### GPS - -```text -get telemetry.gps 1 -> EwB9cmoeGIChAxwAR0i3AgAAAAAAAAAAAAAAAAKAAAAAAIAAAAD/9ABAAX/+AAgAYAAAB//wAD/+gAAAA/+wAP/8ABwAEAEABgAAAA//gAX/7AAv/3/8AAf/oAF//QAYACAAgAc= -``` - ## Reading the table - Timestamps default to UTC. Select **Show browser-local time** to convert them for display and CSV export. +- `TTB1` means a raw temperature snapshot and `TVB1` means a raw voltage + snapshot. The input summary also reports the MeshCore route and path-hop + count when a complete packet was pasted. - Temperature preserves exact whole degrees from `-50 C` through `+77 C`, plus missing, below-range, and above-range states. - Voltage preserves hundredths of a volt from `1.88 V` through `4.40 V`, plus @@ -122,5 +120,10 @@ get telemetry.gps 1 - A GPS clipping warning means at least one movement exceeded the differential range, so positions after that point can be less accurate. +GPS history remains available through the administrator CLI, but `telemetry.tx` +never puts GPS in RAW_CUSTOM packets. Therefore analyzer hex decoding supports +only temperature and voltage; location data cannot be recovered through this +page. + For the byte-level layouts, see [Read repeater telemetry history](cli_commands.md#read-repeater-telemetry-history). diff --git a/scripts/test_telemetry_decoder.js b/scripts/test_telemetry_decoder.js new file mode 100644 index 00000000..a80695bf --- /dev/null +++ b/scripts/test_telemetry_decoder.js @@ -0,0 +1,124 @@ +"use strict"; + +const assert = require("assert"); +const decoder = require("../docs/_javascript/telemetry_decoder.js"); + +let passed = 0; + +function test(name, callback) { + callback(); + passed += 1; + process.stdout.write(`ok ${passed} - ${name}\n`); +} + +function assertDecoderError(callback, pattern) { + assert.throws( + callback, + (error) => error instanceof decoder.TelemetryDecodeError && pattern.test(error.message) + ); +} + +const TEMPERATURE_PACKET = + "3E00545442311122334455667788800092651E0008000102354A4E5082"; +const VOLTAGE_PACKET = + "3E00545642311122334455667788800092651E000800010264C8FEFFDC"; + +test("decodes complete analyzer temperature packet hex", () => { + const decoded = decoder.decodeRawTelemetryHex(TEMPERATURE_PACKET); + assert.strictEqual(decoded.kind, "temperature"); + assert.strictEqual(decoded.typeCode, "TTB1"); + assert.strictEqual(decoded.sourceId, "1122334455667788"); + assert.strictEqual(decoded.firstEpoch, 1704067200); + assert.strictEqual(decoded.intervalMinutes, 30); + assert.strictEqual(decoded.count, 8); + assert.strictEqual(decoded.packet.routeName, "direct"); + assert.strictEqual(decoded.packet.payloadType, 0x0f); + assert.strictEqual(decoded.packet.hopCount, 0); + assert.deepStrictEqual( + decoded.rows.map((row) => [row.status, row.valueC]), + [ + ["Missing", null], + ["Below range", null], + ["Above range", null], + ["Value", 0], + ["Value", 21], + ["Value", 25], + ["Value", 27], + ["Value", 77], + ] + ); +}); + +test("decodes analyzer voltage packet and exact voltage codes", () => { + const decoded = decoder.decodeTelemetry(VOLTAGE_PACKET.toLowerCase()); + assert.strictEqual(decoded.kind, "voltage"); + assert.strictEqual(decoded.typeCode, "TVB1"); + assert.deepStrictEqual( + decoded.rows.map((row) => [row.status, row.millivolts]), + [ + ["Missing", null], + ["Below range", null], + ["Value", 1880], + ["Value", 2860], + ["Value", 3860], + ["Value", 4400], + ["Above range", null], + ["Value", 4060], + ] + ); +}); + +test("accepts payload-only spaced hex", () => { + const payload = TEMPERATURE_PACKET.slice(4).match(/.{2}/g).join(" "); + const decoded = decoder.decodeRawTelemetryHex(payload); + assert.strictEqual(decoded.payloadOffset, 0); + assert.strictEqual(decoded.packet, null); + assert.strictEqual(decoded.sourceId, "1122334455667788"); +}); + +test("extracts raw hex from a quoted analyzer-style field", () => { + const decoded = decoder.decodeTelemetry(`{\n "raw": "${VOLTAGE_PACKET}"\n}`); + assert.strictEqual(decoded.kind, "voltage"); + assert.strictEqual(decoded.packet.isRawCustom, true); +}); + +test("recognizes a routed packet and its path", () => { + const routed = `3E4212345678${TEMPERATURE_PACKET.slice(4)}`; + const decoded = decoder.decodeRawTelemetryHex(routed); + assert.strictEqual(decoded.packet.routeName, "direct"); + assert.strictEqual(decoded.packet.hopCount, 2); + assert.strictEqual(decoded.packet.pathHashBytes, 2); + assert.strictEqual(decoded.payloadOffset, 6); +}); + +test("retains legacy CLI Base64 decoding", () => { + const decoded = decoder.decodeTelemetry(decoder.EXAMPLES.voltage.reply); + assert.strictEqual(decoded.kind, "voltage"); + assert.strictEqual(decoded.count, 48); + assert.strictEqual(decoded.format, undefined); +}); + +test("rejects unrelated, truncated, and ambiguous hex", () => { + assertDecoderError( + () => decoder.decodeRawTelemetryHex("00000000000000000000000000000000000000"), + /does not contain TTB1 temperature or TVB1 voltage/ + ); + assertDecoderError( + () => decoder.decodeRawTelemetryHex(TEMPERATURE_PACKET.slice(0, -2)), + /payload bytes are missing/ + ); + assertDecoderError( + () => decoder.decodeRawTelemetryHex(`${TEMPERATURE_PACKET}${VOLTAGE_PACKET}`), + /More than one telemetry snapshot/ + ); +}); + +test("exports raw telemetry rows as CSV", () => { + const decoded = decoder.decodeRawTelemetryHex(VOLTAGE_PACKET); + const csv = decoder.modelToCsv(decoder.tableModel(decoded, false)); + assert.match(csv, /Timestamp \(UTC\)/); + assert.match(csv, /2\.86 V/); + assert.strictEqual(csv.split("\r\n").length, 9); +}); + +process.stdout.write(`1..${passed}\n`);