@freqgen/feeltech
v0.2.0
Published
TypeScript library and CLI for FeelTech / FeelElec FY-series arbitrary waveform generators (FY2300, FY6300, FY6600, FY6800, FY6900, FY8300). Works in Node.js (serialport) and browsers (Web Serial API).
Maintainers
Readme
@freqgen/feeltech
A comprehensive TypeScript library and CLI for controlling FeelTech / FeelElec FY-series arbitrary waveform generators — FY2300, FY6300, FY6600, FY6800, FY6900, FY8300 — over USB serial. Part of the @freqgen monorepo; the FY3200S/FY3224S ship in this package as a separate FY3200S driver.
Formerly published as
feeltech. The unscoped package still works; new releases are@freqgen/feeltech.
Works in Node.js (via serialport) and in browsers (via the Web Serial API) from a single source tree.
The protocol implementation has been verified empirically against a real FY6300-60M and follows the fygen reference library on the points where the official FeelTech PDFs are wrong (and they are wrong about several scaling factors — see docs/serial_protocol.md).
Features
- Dual environment. Same TypeScript API in Node and the browser. Per-environment transports are dynamically imported, so browser bundlers won't pull in
serialport. - Full command coverage — channel parameters, modulation (AM/FM/PM/ASK/FSK/PSK/Burst), sweep, frequency counter / pulse-width measurement, save/load slots, sync, cascade, identity.
- Arbitrary waveform upload — upload custom waveforms to device memory (14-bit, 8192 samples per waveform), with
resampleWaveform()/normalizeWaveform()helpers for arbitrary-length data. - CLI included —
npx @freqgen/feeltech list | info | set | sweep | measure | waveforms | upload. - Port auto-detection —
connectNode()without a path finds the generator's USB serial adapter (CH340/CP210x/PL2303) automatically; the device path changing between USB ports stops mattering. - Auto-detection of the device family (FY2300 vs. FY6300/6900 family) by querying
UMO. Encoding/decoding is automatically chosen for the detected family. - Robust framing with retry logic — handles trailing empty newlines, slow responses, and timing quirks transparently.
- Verified writes. FY firmware occasionally acks a write without applying it; every parameter setter reads the value back and retries automatically (opt out with
verifyWrites: false). - Strict types. Every command is typed; channel state and measurement results are exposed as plain TypeScript interfaces. Enums are
as constobjects, so the library works underisolatedModulesand Node's type stripping. - Testable without hardware — a
MockTransportships under@freqgen/feeltech/testing. - No build step required for the library — ships pre-built ESM with
.d.tsfiles.
Installation
npm install @freqgen/feeltechThe Node serial backend (serialport) is an optional dependency — npm installs it automatically, so the library and the npx @freqgen/feeltech CLI work out of the box. If its native build ever fails on an exotic platform (or you install with --omit=optional), Node usage will tell you to npm install serialport explicitly.
serialport is not required in the browser (the bundler will see the browser field in package.json and replace it with false, and it never enters your bundle).
Quick start
Node.js
import { connectNode, Channel } from "@freqgen/feeltech";
const fy = await connectNode(); // no path: auto-detects the USB adapter
console.log("Connected to", fy.deviceModel, "(family:", fy.family, ")");
await fy.configureChannel(Channel.Main, {
waveform: "Sine",
frequencyHz: 1000,
amplitudeV: 3.3,
offsetV: 0,
dutyCyclePct: 50,
enabled: true,
});
console.log(await fy.getChannelState(Channel.Main));
await fy.setOutput(Channel.Main, false);
await fy.close();You can also pass an explicit port: connectNode("/dev/cu.wchusbserial1220") or connectNode("COM3").
Browser (Web Serial API)
import { connectWeb, Channel, FEELTECH_USB_FILTERS } from "@freqgen/feeltech";
document.querySelector("#connect").addEventListener("click", async () => {
const fy = await connectWeb({ filters: FEELTECH_USB_FILTERS });
await fy.setWaveform(Channel.Main, "Sine");
await fy.setFrequency(Channel.Main, 1000);
await fy.setAmplitude(Channel.Main, 3.3);
await fy.setOutput(Channel.Main, true);
});The browser must support Web Serial (Chrome, Edge, Opera). Page must be served over HTTPS or localhost.
CLI
npx @freqgen/feeltech list # list serial ports (* = FeelTech-likely)
npx @freqgen/feeltech info # model, ID, family, both channel states
npx @freqgen/feeltech set --channel 1 --waveform sine --freq 1000 --amp 3.3 --on
npx @freqgen/feeltech set --channel 1 --off
npx @freqgen/feeltech sweep --object freq --start 100 --end 10000 --time 5
npx @freqgen/feeltech sweep --stop
npx @freqgen/feeltech measure --gate 1
npx @freqgen/feeltech waveforms --channel 2
npx @freqgen/feeltech upload --slot 1 --file wave.json --resample --normalize
npx @freqgen/feeltech --helpThe port is auto-detected; pass --port /dev/cu.wchusbserial1220 (or COMx) to pin it. --json switches every command to machine-readable output.
Finding the device (Node)
import { findDevices, listPorts } from "@freqgen/feeltech/node";
console.log(await findDevices()); // FeelTech-likely ports only (by USB vendor ID)
console.log(await listPorts()); // every serial portFeelTech generators usually appear as a CH340 USB UART (vendor 1a86, product 7523) — on macOS as /dev/cu.wchusbserial*, on Linux as /dev/ttyUSB*, and on Windows as COMx. The numeric suffix changes when you plug into a different USB port, which is why auto-detection is the default.
API overview
Channel numbering: the device's front panel says CH1/CH2; in code these are Channel.Main (0) and Channel.Aux (1).
Connection
| Helper | Description |
|---|---|
| connectNode(path?, options?) | Opens a Node serialport (auto-detected when path is omitted) and returns an open FeelTech. |
| connectWeb(options?) | Browser-only; calls navigator.serial.requestPort() then opens it. |
| new FeelTech(transport, options?) | Inject your own Transport (e.g. MockTransport for testing). |
Channel commands
await fy.setWaveform(Channel.Main, "Sine"); // by name
await fy.setWaveform(Channel.Main, 0); // by code
await fy.setWaveform(Channel.Aux, "Arbitrary3"); // arbitrary slot
await fy.setFrequency(Channel.Main, 1_000_000); // 1 MHz
await fy.setAmplitude(Channel.Main, 3.3); // V
await fy.setOffset(Channel.Main, -1.5); // V (signed)
await fy.setDutyCycle(Channel.Main, 25.0); // %
await fy.setPhase(Channel.Main, 90.0); // degrees
await fy.setOutput(Channel.Main, true);
const state = await fy.getChannelState(Channel.Main);
// → { waveform, waveformName, frequencyHz, amplitudeV, offsetV, dutyCyclePct, phaseDeg, enabled }Out-of-range values (duty > 100 %, negative frequency, …) throw a FeelTechError instead of being sent to the device.
configureChannel(channel, partial) applies many fields in one call:
await fy.configureChannel(Channel.Main, {
waveform: "Square",
frequencyHz: 1e6,
amplitudeV: 5,
enabled: true,
});Modulation
import { ModulationMode, ModulationSource } from "@freqgen/feeltech";
await fy.setModulationMode(ModulationMode.AM);
await fy.setModulationSource(ModulationSource.CH2);
await fy.setAmModulationRate(50.0); // 50%
await fy.setBurstCount(10);
await fy.manualTrigger(); // FY6900 onlySweep
import { SweepObject, SweepMode } from "@freqgen/feeltech";
await fy.configureSweep({
object: SweepObject.Frequency,
start: 100,
end: 10_000,
timeSeconds: 5,
mode: SweepMode.Linear,
});
await fy.startSweep();
// ... later
await fy.stopSweep();Offset sweeps on the FY6900 family need a +10 V bias on the wire (a firmware quirk); the library applies it automatically.
Arbitrary waveform upload
const values = Array.from({ length: 8192 }, (_, i) => Math.sin(i * 0.001));
await fy.uploadWaveform(1, values, { minValue: -1, maxValue: 1 });
await fy.configureChannel(Channel.Main, {
waveform: "Arbitrary1",
frequencyHz: 1000,
amplitudeV: 3,
enabled: true,
});uploadWaveform requires exactly 8192 samples — pass { resample: true } to linearly resample arbitrary-length data, and/or { normalize: true } to scale it into −1…+1. The standalone helpers are exported too:
import { resampleWaveform, normalizeWaveform } from "@freqgen/feeltech";
const samples = resampleWaveform(normalizeWaveform(rawData)); // → 8192 points, −1…+1Note: the upload implements the
DDS_WAVEprotocol with 8192 × 14-bit samples, as used by the FY6600/6800/6900/6300 family (verified on an FY6300-60M). The older FY2300-class 2048 × 16-bit upload variant is not implemented yet.
Frequency counter / measurement
import { GateTime } from "@freqgen/feeltech";
await fy.setGateTime(GateTime.OneSecond);
await fy.resetCounter();
const m = await fy.readMeasurement();
// → { frequencyHz, count, periodNs, positivePulseNs, negativePulseNs, dutyCyclePct, gateTime }System settings
await fy.saveState(1); // save to slot 1
await fy.loadState(1); // load from slot 1
await fy.enableSync(SyncObject.Frequency);
await fy.setBuzzer(false);
await fy.readModel(); // "FY6300-60M"
await fy.readId();Low-level escape hatch
If you need a command that's not wrapped, send it directly:
await fy.sendWrite("WMW", "00"); // raw write
const raw = await fy.sendRead("RMF"); // raw readTesting your code without hardware
import { FeelTech } from "@freqgen/feeltech";
import { MockTransport } from "@freqgen/feeltech/testing";
const mock = new MockTransport({ family: "FY6900" });
const fy = new FeelTech(mock);
await fy.open();
await fy.setFrequency(0, 1000);
console.log(mock.writes); // ["…", "WMF00001000.000000\n"]The mock reproduces the FY-series response framing (write acks, <value>\n\n reads, the UMO special case, and the DDS_WAVE binary upload flow), and remembers written values so reads round-trip.
Constructor options
const fy = await connectNode(undefined, {
family: "FY6900", // override auto-detection ("FY2300" | "FY6900" | "Unknown")
baudRate: 115200, // override family default
frequencyEncoding: "hz", // "hz" | "uHz" — see Troubleshooting
readTimeoutMs: 1500, // per-read timeout
readRetries: 2, // retries on no-response
verifyWrites: true, // read setters back and retry dropped writes (default)
writeRetries: 2, // retries per verified write
commandDelayMs: 0, // pause after every command
debug: true, // log every TX/RX line
logger: (m, ...a) => console.log("[fy]", m, ...a),
});Device family differences
| | FY2300 | FY6300 / FY6900 family |
|---|---|---|
| Baud rate | 9600 | 115200 |
| Stop bits | 1 | 2 |
| Write ack | none | \n |
| Read framing | <value>\n | <value>\n\n (UMO: <value>\n\n\n\n) |
| Frequency | 14-digit µHz integer | decimal Hz, format %015.6f |
| Amplitude scale | int / 100 | int / 10000 |
| Offset encoding | bias-1000 | signed 32-bit / 1000 |
| Duty scale | int / 10 | int / 1000 |
| Phase scale | int (deg) | int / 1000 |
| Built-in waveforms | 31 | 37 (CH1) / 36 (CH2) |
| Arbitrary slots | 16 | 64 |
The library handles all of this; your code stays the same regardless of family.
Examples
The repo includes runnable examples organized by topic. <port> is optional everywhere — omit it to auto-detect (the -- after the script name is required by npm).
Basic examples
| Script | Description |
|---|---|
| npm run example:simple -- <port> | Minimalist: 1 kHz square wave |
| npm run example:basic -- <port> | Sine wave with 2-second output |
| npm run example:info -- <port> | Probe model, ID, and both channel states |
| npm run example:allwaves -- <port> | Cycle through all waveforms |
| npm run example:list | List all serial ports |
Modulation examples
| Script | Description |
|---|---|
| npm run example:mod:am -- <port> | AM modulation: 2 kHz + 150 Hz |
| npm run example:mod:fm -- <port> | FM modulation: 2 kHz + 150 Hz |
| npm run example:mod:pm -- <port> | PM modulation: 1 kHz + 500 Hz |
| npm run example:mod:fsk -- <port> | FSK: toggle between 1/2 kHz |
| npm run example:mod:psk -- <port> | PSK: phase-shift keying |
| npm run example:mod:burst -- <port> | Burst mode: N cycles per trigger |
Sweep examples
| Script | Description |
|---|---|
| npm run example:sweep:freq -- <port> | Frequency sweep |
| npm run example:sweep:amp -- <port> | Amplitude sweep |
| npm run example:sweep:offset -- <port> | Offset sweep |
| npm run example:sweep:duty -- <port> | Duty cycle sweep |
Measurement examples
| Script | Description |
|---|---|
| npm run example:measure -- <port> | Read frequency counter |
| npm run example:counter -- <port> | Count pulses for 10 seconds |
| npm run example:calibrate -- <port> | Inspect raw responses |
| npm run example:roundtrip -- <port> | Verify write→read accuracy |
Arbitrary waveform examples
| Script | Description |
|---|---|
| npm run example:arb -- <port> | Upload and play a stairstep |
| npm run example:star -- <port> | XY star plot (oscilloscope XY mode) |
| npm run example:gcode -- <port> examples/gcode/star.gcd | XY plot from G-code |
System & debug examples
| Script | Description |
|---|---|
| npm run example:system -- <port> | Buzzer, uplink, sync, save/load |
| npm run example:debug -- <port> | Wire-level debug with hex dumps |
| npm run example:raw:cmd -- <port> | Send individual commands |
| npm run example:raw:bytes -- <port> | Raw byte-level protocol |
| npm run example:raw:dump -- <port> | Hex-print all responses |
Demo mode
npm run example:demo -- <port>Continuously cycles through waveforms, sweeps, modulation, and arbitrary waveforms for demonstration purposes. Press Ctrl+C to stop.
Browser example (web control panel)
npm run example:webThis builds the library and serves the repo on http://localhost:3000 — then open http://localhost:3000/examples/web/basic.html in Chrome or Edge. (The page loads ES modules from dist/, which browsers block over file://, so it must be served — any static server on localhost works.)
Hardware smoke test
With a generator connected you can run the protocol-quirk verification script (it briefly drives CH1 and restores a neutral state):
npm run test:hw -- /dev/cu.wchusbserial1220Architecture
src/
├── index.ts # public exports + connectNode()/connectWeb() helpers
├── feeltech.ts # high-level FeelTech device class
├── protocol.ts # encode/decode helpers (per family)
├── waveforms.ts # waveform tables, resolver & generic-kind mapping
├── waveform-utils.ts # resampleWaveform() / normalizeWaveform()
├── limits.ts # frequency limits read from the reported model string
├── devices.ts # registry descriptors
├── testing.ts # MockTransport (exported as @freqgen/feeltech/testing)
├── cli.ts # `feeltech` command-line tool
├── transports/
│ ├── node.ts # re-exports @freqgen/core/node
│ └── web.ts # re-exports @freqgen/core/web
└── types.ts # enums, error aliases, result types
examples/
├── basic/ # simple, info, list_ports, all_waves, sine_wave
├── modulation/ # am, fm, pm, fsk, psk, burst
├── sweep/ # sweep_frequency, sweep_amplitude, sweep_offset, sweep_duty
├── measurement/ # measure, counter, calibrate, roundtrip
├── arb/ # simple_arb, xy_star_plot, xy_gcode_plot
├── system/ # settings
├── lowlevel/ # debug, raw_commands, raw_bytes, raw_dump, raw_init_variations
├── gcode/ # sample .gcd files (star, cat)
└── web/ # basic.html (Web Serial control panel)The Transport interface is intentionally minimal (open, write, readLine, flush, close). You can plug in a mock transport in tests, or wrap a TCP-to-serial bridge. It lives in @freqgen/core along with the serial transports, which the Spooky2 drivers share.
Driving other generators
FeelTech implements the vendor-neutral SignalGenerator interface from @freqgen/core, so the same code can drive a different make of generator:
import type { SignalGenerator, ChannelStep } from "@freqgen/feeltech";
async function play(gen: SignalGenerator, step: ChannelStep) {
await gen.applyStep(0, step);
}applyStep() is the portable call — on the FY series it is a rename of configureChannel() emitting identical wire traffic, while generators with a required command ordering implement it as their native sequence.
| Package | Devices |
| --- | --- |
| @freqgen/juntek | JUNTEK JDS6600/2800/2900/8000, Koolertron CJDS66 & MHS-5200A |
| @freqgen/spooky2 | Spooky2 XM, Gen X, Gen X Pro |
| @freqgen/core | shared transports, capabilities, limits, octave folding |
The FY3200S is a separate driver
FY3200S covers the FY3200S/FY3224S, which share a manufacturer with the rest of
this package and nothing else — 9600 8N1, lowercase commands, a b/d channel
prefix, centihertz, and no output relay, so setOutput() is emulated by
writing the amplitude. It reports capabilities.outputRelay === false. Not
verified against hardware.
Capabilities and limits
const { limits, waveforms, dutyGatedByWaveform } = fy.capabilities;Frequency limits carry their own provenance. The sine maximum is read from the bandwidth marker the device reports in its model string (FY6300-60M → 60 MHz). Square and arbitrary bandwidth stay null — they are lower than sine, FeelTech does not document them, and square is what actually constrains most work, so a guess there would be worse than an admission:
import { resolveCap } from "@freqgen/feeltech";
resolveCap(limits, "sine"); // { hz: 60_000_000, source: "spec" }
resolveCap(limits, "square"); // { hz: 60_000_000, source: "assumed-sine" }Protocol reference
A full protocol reference, derived from the official FeelTech PDFs and corrected against real-device measurements, lives in docs/serial_protocol.md. It covers every command, the exact byte framing, parameter encoding, model differences, and known firmware quirks.
Troubleshooting
No FeelTech-like USB serial adapter found — auto-detection matches USB vendor IDs 1a86 (CH340), 10c4 (CP210x) and 067b (PL2303). If your adapter reports something else, pass the port path explicitly. Run npx @freqgen/feeltech list to see every port.
Failed to open <path> — On macOS use /dev/cu.* (not /dev/tty.*); on Linux check dmesg | tail after plugging in to find the device, and ensure your user is in the dialout group.
Garbled / shifted readings — The library handles trailing empty newlines and retry logic automatically. If you bypass sendRead and read the port directly, drain the trailing empties yourself.
Reads time out on FY6900 — Ensure the transport is opened with 2 stop bits. The library does this automatically when family is FY6900/Unknown but not if you override family: "FY2300" on a 6900-series device.
FY6900 sets a wildly wrong frequency (off by ~10⁶) — some older FY6900 firmware expects the frequency as a 14-digit µHz integer instead of decimal Hz. Pass frequencyEncoding: "uHz" in the options.
FeelTechVerifyError: … was not applied by the device — on some firmware (observed on the FY6300-60M) the device occasionally acks a write without applying it, especially right after sweep or sync-mode commands. The library retries automatically (writeRetries); this error means the value still didn't stick after all attempts — usually because the firmware clamped an out-of-range value (e.g. a frequency beyond the device's maximum). Pass verifyWrites: false to send writes blind. Note that save slots (saveState) snapshot the applied state, so pause briefly after parameter writes before saving.
Web Serial: "No port selected" — navigator.serial.requestPort() must be called from a user gesture (click handler).
License
MIT © Manuel Haller Polo
Based on protocol research from the fygen project (LGPL-2.1) by Matt Wach. This library is a clean-room TypeScript implementation; only the protocol semantics are derived from fygen.
