diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..9c1207c
--- /dev/null
+++ b/.gitea/workflows/deploy.yml
@@ -0,0 +1,43 @@
+name: Plant Growing Automation
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ deploy:
+ runs-on: pigrow
+
+ steps:
+ - name: Checkout code
+ run: |
+ cd ~/apps/grow
+ git fetch
+ git checkout main
+ git pull origin main
+ - name: Install dependencies
+ run: |
+ cd ~/apps/grow
+ if [ -f package-lock.json ] || [ -f package.json ]; then
+ echo "Installing npm dependencies..."
+ npm install
+ else
+ echo "No Node.js project found (missing package.json)"
+ exit 1
+ fi
+
+
+ - name: Stop existing screen session, if running
+ run: |
+ if screen -list | grep -q "grow_server"; then
+ echo "Stopping existing screen session..."
+ screen -S grow_server -X quit
+ fi
+
+ - name: Start server in screen session
+ run: |
+ cd ~/apps/grow
+
+ setsid screen -dmS grow_server bash -c 'HTTP_PORT=8080 WS_PORT=3003 npm start >server.log 2>&1'
+ echo "Server started in detached screen session"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c3076ac
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+venv/*
+node_modules/*
+dist/*
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7c6092b
--- /dev/null
+++ b/package.json
@@ -0,0 +1,27 @@
+{
+ "dependencies": {
+ "@roamhq/wrtc": "0.8.0",
+ "node-dht-sensor": "^0.4.5",
+ "node-pre-gyp": "^0.17.0",
+ "pigpio": "^3.3.1",
+ "sass": "1.86.3",
+ "serialport": "^13.0.0",
+ "ws": "^8.18.0"
+ },
+ "scripts": {
+ "build:scss": "npx sass src/static/css:dist/static/css",
+ "copy:html": "cp src/static/*.* dist/static",
+ "build:js:fe": "npx tsc src/static/js/*.ts --outDir dist/static/js",
+ "build:js:be": "npx tsc --skipLibCheck src/*.ts --outDir dist",
+ "build": "npm run build:js:fe && npm run build:js:be && npm run build:scss && npm run copy:html",
+ "start": "npm run build && node dist/server.js",
+ "dev": "npm run build && npx ts-node src/server.ts"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.2",
+ "@types/node-dht-sensor": "^0.4.2",
+ "@types/ws": "^8.5.13",
+ "ts-node": "^10.9.2",
+ "typescript": "^5.8.2"
+ }
+}
diff --git a/sketch.c b/sketch.c
new file mode 100644
index 0000000..a5db3ad
--- /dev/null
+++ b/sketch.c
@@ -0,0 +1,16 @@
+void setup() {
+ Serial.begin(9600);
+}
+
+void loop() {
+ int moisture0 = analogRead(A0);
+ delayMicroseconds(100);
+ analogRead(A1);
+ int moisture1 = analogRead(A1);
+ Serial.print("{\"A0\":");
+ Serial.print(moisture0);
+ Serial.print(",\"A1\":");
+ Serial.print(moisture1);
+ Serial.println("}");
+ delay(1000);
+}
\ No newline at end of file
diff --git a/src/http.ts b/src/http.ts
new file mode 100644
index 0000000..5f0d576
--- /dev/null
+++ b/src/http.ts
@@ -0,0 +1,67 @@
+import * as http from "http";
+import * as fs from "fs";
+import * as path from "path";
+import IO from "./io";
+
+
+ class HttpServer {
+
+ private httpServer: http.Server;
+ private port: number;
+ private root: string;
+
+ public constructor(port: number, root: string, test: (device:string)=>void) {
+ this.port = port;
+ this.root = root;
+ this.httpServer = http.createServer((req, res) => {
+ let status: number = 404;
+ let body: any = "";
+ const url = new URL(req.url, `http://${req.headers.host}`);
+ const pathname = path.normalize(url.pathname);
+
+ if (pathname.startsWith('/api/')) {
+ const query = pathname.split('/');
+ const api = query[2];
+ switch (req.method) {
+ case "GET":
+ switch (api) {
+ }
+ break;
+ case "PUT":
+ switch (api) {
+ case "test":
+ const device: string = query[3];
+ test(device);
+ status = 202;
+ break;
+ }
+ }
+ }
+ else if (req.method === 'GET') {
+ const filePath = path.join(root, path.extname(pathname) === '' ? pathname + "/index.html" : pathname);
+ try {
+ body = fs.readFileSync(filePath);
+ status = 200;
+ }
+ catch (err) {
+ body = "Invalid File"
+ }
+ }
+ else {
+ body = "Invalid Request"
+ }
+ res.writeHead(status);
+ res.end(body);
+
+ });
+ }
+ public start() {
+ this.httpServer.listen(this.port, () => {
+ console.log(`Serving ${this.root} at http://localhost:${this.port}`);
+ });
+ }
+
+
+};
+export default HttpServer;
+
diff --git a/src/io.ts b/src/io.ts
new file mode 100644
index 0000000..9e88a5a
--- /dev/null
+++ b/src/io.ts
@@ -0,0 +1,73 @@
+import Serial from "./serial";
+import * as pigpio from 'pigpio';
+import * as dht from 'node-dht-sensor';
+
+type ONOFF = 0 | 1;
+const ON: ONOFF = 0
+const OFF: ONOFF = 1
+
+
+
+export interface ISensors {
+ temperature: number;
+ lights: boolean;
+ heat: boolean
+ moisture0: number;
+ moisture1: number;
+ humidity: number;
+}
+
+export default class IO {
+ gpioLights: pigpio.Gpio
+ gpioHeat: pigpio.Gpio
+ serial: Serial;
+ lights: boolean;
+ heat: boolean;
+ getMoisture: () => ({ moisture0: number, moisture1: number });
+
+ public constructor(LIGHTS_GPIO = 17, HEAT_GPIO = 22, DHT_GPIO = 27, DHT_MODEL = 22) {
+ const serial = new Serial()
+ this.getMoisture = serial.getMoisture.bind(serial);
+ this.serial = serial;
+
+ const readLights = new pigpio.Gpio(LIGHTS_GPIO, { mode: pigpio.Gpio.INPUT });
+ this.lights = readLights.digitalRead() == ON ? true : false
+ this.gpioLights = new pigpio.Gpio(LIGHTS_GPIO, { mode: pigpio.Gpio.OUTPUT });
+
+ const readHeat = new pigpio.Gpio(HEAT_GPIO, { mode: pigpio.Gpio.INPUT });
+ this.heat = readHeat.digitalRead() == ON ? true : false
+ this.gpioHeat = new pigpio.Gpio(HEAT_GPIO, { mode: pigpio.Gpio.OUTPUT });
+
+
+ dht.initialize(22, DHT_GPIO);
+ dht.setMaxRetries(10);
+ }
+
+ public setPower(state: boolean, GPIO = 17) {
+ if (GPIO == 17) {
+ this.lights = state;
+ this.gpioLights.digitalWrite(this.lights ? ON : OFF);
+ }
+ else if (GPIO == 22) {
+ this.heat = state;
+ this.gpioHeat.digitalWrite(this.heat ? ON : OFF);
+ }
+
+ }
+
+ private round = (n) =>
+ (n * 100) / 100
+
+ public getSensors(): ISensors {
+ const { temperature, humidity } = dht.read(22, 27)
+ const { moisture0, moisture1 } = this.getMoisture()
+ return {
+ lights: this.lights,
+ heat: this.heat,
+ moisture0: moisture0,
+ moisture1: moisture1,
+ temperature: this.round(temperature),
+ humidity: this.round(humidity)
+ }
+ }
+}
diff --git a/src/programs.json b/src/programs.json
new file mode 100644
index 0000000..0d90b40
--- /dev/null
+++ b/src/programs.json
@@ -0,0 +1,7 @@
+[
+ {
+ "name": "tomato",
+ "daylightHours": 16,
+ "soilMoisture": 75
+ }
+]
\ No newline at end of file
diff --git a/src/serial.ts b/src/serial.ts
new file mode 100644
index 0000000..126ae3a
--- /dev/null
+++ b/src/serial.ts
@@ -0,0 +1,51 @@
+import * as serialport from 'serialport';
+
+// interface ISerial {
+// moisture: number;
+// parser: serialport.ReadlineParser
+// }
+
+export default class Serial {
+ private moisture0: number;
+ private moisture1: number;
+
+ public constructor(path = '/dev/ttyUSB0', baudRate = 9600, delimiter = '\n') {
+ const port = new serialport.SerialPort({ path, baudRate });
+ const readline = new serialport.ReadlineParser({ delimiter });
+ const parser = port.pipe(readline);
+ parser.on('data', line => {
+ try{
+ const data = JSON.parse(line)
+ const moisture0 = parseInt(data['A0'], 10);
+ const moisture1 = parseInt(data['A1'], 10);
+ if (isNaN(moisture0) || isNaN(moisture1)){
+ throw new Error("isNan " + moisture0 + " " + moisture1)
+ }
+ this.moisture0 = moisture0;
+ this.moisture1 = moisture1;
+ }
+ catch(e){
+ console.log(e)
+ }
+
+ });
+ port.on('error', err => {
+ console.error('Serial Error:', err.message);
+ });
+ }
+
+ private scale = (value: number) => {
+ const percent = (value / 1024) * 100;
+ return Math.min(Math.max(percent, 0), 100); // Clamp to 0-100 just in case
+ }
+
+ public getMoisture() {
+ return { moisture0: this.scale(this.moisture0), moisture1: this.scale(this.moisture1) };
+ }
+
+
+
+
+
+
+}
\ No newline at end of file
diff --git a/src/server.ts b/src/server.ts
new file mode 100644
index 0000000..63384f6
--- /dev/null
+++ b/src/server.ts
@@ -0,0 +1,95 @@
+import HttpServer from './http';
+import IO, { ISensors } from './io';
+import VideoSocket from './ws';
+import * as programs from './programs.json'
+
+const HTTP_PORT = process.env.HTTP_PORT ? parseInt(process.env.HTTP_PORT, 10) : 8080;
+const WS_PORT = process.env.WS_PORT ? parseInt(process.env.WS_PORT, 10) : 3003;
+const STATIC_ROOT = process.cwd() + "/dist/static";
+const TV_DEV_0 = process.env.TV_DEV_0 ?? '/dev/video0'
+const GPIO_LIGHTS = parseInt(process.env.GPIO_LIGHTS) ?? 17;
+const GPIO_HEAT = parseInt(process.env.GPIO_HEAT) ?? 22;
+const START_HOUR = parseInt(process.env.START_HOUR) ?? 8;
+
+interface IProgram {
+ name:string,
+ daylightHours: number,
+ soilMoisture: number
+}
+
+const io = new IO();
+const getSensors: () => ISensors = io.getSensors.bind(io);
+
+const test = (device: string) => {
+ const gpio = device == "lights" ? GPIO_LIGHTS : device == "heat" ? GPIO_HEAT : NaN
+ if (!isNaN(gpio)) {
+ const state = io[device];
+ io.setPower(!state, gpio)
+ setTimeout(() => {
+ io.setPower(state, gpio)
+ }, 4000)
+ }
+
+}
+
+const runProgram = async (ID = 0) =>{
+ let state = false;
+ const program: IProgram = programs[ID];
+ const {daylightHours, soilMoisture} = program;
+ const now = new Date();
+ const startTime = new Date();
+ startTime.setHours(START_HOUR, 0, 0, 0);
+ const endTime = new Date(startTime.getTime());
+ endTime.setHours(startTime.getHours() + daylightHours);
+ if(now >= startTime && now <= endTime){
+ state = true;
+ }
+ io.setPower(state,GPIO_LIGHTS);
+ io.setPower(state,GPIO_HEAT);
+}
+runProgram();
+
+const httpServer = new HttpServer(HTTP_PORT, STATIC_ROOT, test);
+const videoSocket = new VideoSocket(WS_PORT, TV_DEV_0, getSensors);
+
+httpServer.start();
+
+
+process.stdin.setEncoding("utf8");
+process.stdin.resume();
+
+console.log("Menu:\n1)Lights Power off\n2) Lights Power on\n3)Heat Power Off\n4) Heat power on\n5)Read Sensors");
+
+process.stdin.on("data", async (data: string) => {
+ const input = data.trim();
+ console.log(`Received: "${input}"`);
+ const val = parseInt(input);
+ switch (val) {
+ case 0:
+
+ break;
+ case 1:
+ io.setPower(false, GPIO_LIGHTS);
+ break;
+ case 2:
+ io.setPower(true, GPIO_LIGHTS);
+ break;
+ case 3:
+ io.setPower(false, GPIO_HEAT);
+ break;
+ case 4:
+ io.setPower(true, GPIO_HEAT);
+ break;
+ case 5:
+ console.log(getSensors())
+ break;
+ default:
+ console.log("No option for " + input)
+ }
+});
+
+
+
+
+
+
diff --git a/src/static/css/styles.scss b/src/static/css/styles.scss
new file mode 100644
index 0000000..700d609
--- /dev/null
+++ b/src/static/css/styles.scss
@@ -0,0 +1,60 @@
+$background-color: #c7c7c7;
+$primary-color: #333;
+$secondary-color: #d11414;
+$text-color: #123455;
+
+body {
+ font-family: sans-serif;
+ background-color: $background-color;
+ header {
+ nav {
+ ul {
+ display: flex;
+ list-style: none;
+ gap: 1em;
+ li {
+
+ }
+ }
+ a {
+ &:hover {}
+ }
+ }
+ }
+
+ main {
+ display: flex;
+ flex-direction: column;
+ text-align: center;
+ align-items: center;
+ justify-content: center;
+ h1 {}
+
+ p {}
+ img {
+ width: 20em;
+ border-radius: 50%;
+ }
+ .content{
+ display: flex;
+ flex-direction: row;
+ gap: 1em;
+ .player{
+
+ .channel-group{
+ display: flex;
+ align-self: center;
+ justify-self: center;
+ }
+ }
+ }
+
+ }
+
+
+ footer {
+ text-align: center;
+ // background: #eee;
+ padding: 1em;
+ }
+}
\ No newline at end of file
diff --git a/src/static/favicon.ico b/src/static/favicon.ico
new file mode 100644
index 0000000..81b6bd8
Binary files /dev/null and b/src/static/favicon.ico differ
diff --git a/src/static/index.html b/src/static/index.html
new file mode 100644
index 0000000..d31f62a
--- /dev/null
+++ b/src/static/index.html
@@ -0,0 +1,33 @@
+
+
+
+
+ Grow
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/static/js/api.ts b/src/static/js/api.ts
new file mode 100644
index 0000000..e15359d
--- /dev/null
+++ b/src/static/js/api.ts
@@ -0,0 +1,4 @@
+const test = (gpio: string ) => {
+ fetch(`/api/test/${gpio}`, { method: "PUT"})
+};
+
diff --git a/src/static/js/video.ts b/src/static/js/video.ts
new file mode 100644
index 0000000..e28e11c
--- /dev/null
+++ b/src/static/js/video.ts
@@ -0,0 +1,87 @@
+const isSecure = window.location.protocol === 'https:';
+const host = window.location.hostname;
+const ws0builder = isSecure ? `wss://${host}/ws3` : `ws://${host}:3003`;
+const ws0 = new WebSocket(ws0builder);
+
+interface IData {
+ power: boolean,
+ moisture: number,
+ temperature: number,
+ humidity: number
+}
+const dataContainer = document.getElementById('data-container') as HTMLDivElement
+const config = {
+ iceServers: [
+ {
+ urls: [
+ 'stun:dwestgate.us:3478',
+ 'turn:dwestgate.us:3478?transport=udp',
+ 'turn:dwestgate.us:3478?transport=tcp'
+ ],
+ username: 'webrtcuser',
+ credential: 'webrtccred'
+ }
+ ]
+};
+const pc0 = new RTCPeerConnection(isSecure ? config : {iceServers: []});
+const video0 = document.getElementById('video0') as HTMLVideoElement;
+
+
+pc0.ontrack = (event) => {
+ console.log("Received track event", event.streams);
+ video0.srcObject = event.streams[0];
+};
+
+pc0.onicecandidate = ({ candidate }) => {
+ if (candidate) {
+ console.log("Ice candidate: ",candidate.candidate)
+ ws0.send(JSON.stringify({ type: 'ice-candidate', data: candidate }));
+ }
+};
+
+
+// Create the data channel (client initiates)
+const dataChannel = pc0.createDataChannel('sensors');
+console.log("📡 Data channel created by client");
+
+dataChannel.onopen = () => {
+ console.log('📬 Client: Data channel opened');
+ setInterval(() => {
+ if (dataChannel.readyState === 'open') {
+ dataChannel.send('keep-alive');
+ }
+ }, 15000);
+};
+
+dataChannel.onmessage = (event) => {
+ // console.log("📦 Client received message:", event.data);
+ const json = JSON.parse(event.data) ;
+
+ dataContainer.innerHTML = Object.entries(json).map(([k,v])=>`${k}:${v}
`).join('')
+
+};
+
+dataChannel.onclose = () => {
+ console.log("📴 Client: Data channel closed");
+};
+
+ws0.onopen = async () => {
+ pc0.addTransceiver('video', { direction: 'recvonly' });
+ pc0.addTransceiver('audio', { direction: 'recvonly' })
+ const offer = await pc0.createOffer();
+ await pc0.setLocalDescription(offer);
+ ws0.send(JSON.stringify({ type: 'offer', data: offer }));
+}
+
+ws0.onmessage = async (message) => {
+ const msg = JSON.parse(message.data);
+ if (msg.type === 'answer') {
+ // pc0.data
+ await pc0.setRemoteDescription(msg.data);
+ }
+ else if (msg.type === 'ice-candidate') {
+ await pc0.addIceCandidate(msg.data);
+ }
+};
+
+
diff --git a/src/ws.ts b/src/ws.ts
new file mode 100644
index 0000000..09176e9
--- /dev/null
+++ b/src/ws.ts
@@ -0,0 +1,269 @@
+import { MediaStream, MediaStreamTrack, nonstandard, RTCPeerConnection, RTCDataChannel } from '@roamhq/wrtc';
+import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
+import * as ws from 'ws';
+import { ISensors } from './io';
+
+// Constants
+const WIDTH = 640; // Video width
+const HEIGHT = 480; // Video height
+const FRAME_SIZE = WIDTH * HEIGHT * 1.5; // YUV420P frame size
+
+export default class VideoSocket {
+ videoDevice: string;
+ public constructor(port: number, videoDevice, getSensors: () => ISensors) {
+ this.videoDevice = videoDevice
+ const ffmpegProcess = this.startFFmpeg();
+ const videoTrack = this.createVideoTrack(ffmpegProcess);
+ const audioTrack = this.createAudioTrack(ffmpegProcess);
+
+
+ // ffmpegProcess.stdio[2].on('data', data => {
+ // console.log("stdio[2] ",data.toString())
+ // })
+ ffmpegProcess.stdio[2].on('error', data => {
+ console.log("stdio[2] error",data.toString())
+ })
+
+
+ ffmpegProcess.stderr.on('data', (data) => {
+ // console.error(`ffmpeg stderr data: ${data}`);
+ });
+
+ // WebSocket signaling server
+ const wss = new ws.WebSocketServer({ port });
+
+ wss.on('connection', async (ws: ws.WebSocket) => {
+ const peerConnection: RTCPeerConnection = this.createPeerConnection(videoTrack, audioTrack);
+ // The client created the data channel. The server should access it as follows:
+ peerConnection.ondatachannel = (event) => {
+ const dataChannel = event.channel; // This is the data channel created by the client
+
+ dataChannel.onopen = () => {
+ console.log('📬 Server: Data channel opened');
+ // Now you can send data through the channel
+ setInterval(() => {
+ if (dataChannel.readyState === 'open') {
+ const sensorData = getSensors(); // Example function to fetch data
+ dataChannel.send(JSON.stringify(sensorData)); // Send data to the client
+ }
+
+ }, 1000);
+ };
+
+ dataChannel.onmessage = (event) => {
+ // console.log("📦 Server received message:", event.data);
+ };
+
+ dataChannel.onclose = () => {
+ console.log("📴 Server: Data channel closed");
+ };
+ };
+
+
+
+ ws.on('message', async (message: Buffer) => {
+ const { type, data } = JSON.parse(message.toString());
+
+ if (type == 'offer') {
+
+ await peerConnection.setRemoteDescription(data);
+ const answer = await peerConnection.createAnswer();
+ await peerConnection.setLocalDescription(answer);
+ ws.send(JSON.stringify({ type: 'answer', data: peerConnection.localDescription }));
+ }
+
+ if (type === 'ice-candidate') {
+ await peerConnection.addIceCandidate(data);
+ }
+
+ });
+
+ peerConnection.oniceconnectionstatechange = () => {
+ if (peerConnection.iceConnectionState === 'failed') {
+ console.error('ICE connection failed');
+ }
+ };
+
+
+ // Send ICE candidates to the client
+ peerConnection.onicecandidate = ({ candidate }) => {
+ if (candidate) {
+ ws.send(JSON.stringify({ type: 'ice-candidate', data: candidate }));
+ }
+ };
+
+ ws.on('close', () => {
+ console.log('Client disconnected');
+ peerConnection.close();
+ });
+ });
+ }
+
+ // Function to start FFmpeg and capture raw video
+ startFFmpeg = (): ChildProcessWithoutNullStreams => {
+ const p = spawn('ffmpeg', [
+ // '-loglevel', 'debug',
+ '-i', this.videoDevice,
+
+ // Video
+ '-map', '0:v:0',
+ '-framerate', '24',
+ '-video_size', `${WIDTH}x${HEIGHT}`,
+ '-vf', `scale=${WIDTH}:${HEIGHT}:flags=fast_bilinear`,
+ '-pix_fmt', 'yuv420p',
+ '-f', 'rawvideo',
+ '-threads', '1',
+
+ //quality
+ // '-fflags', '+discardcorrupt',
+ // '-err_detect', 'ignore_err',
+ // '-analyzeduration', '100M',
+ // '-probesize', '100M',
+
+ 'pipe:3',
+
+ // Audio
+ // '-map', '0:a:0',
+ // // '-acodec', 'pcm_s16le',
+ // '-ac', '1',
+ // '-ar', '48000',
+ // '-f', 'alsa',
+ // 'pipe:4'
+
+ ], {
+ stdio: ['ignore', 'pipe', 'pipe', 'pipe'/*, 'pipe'*/]
+ });
+ process.on('SIGINT', () => {
+ console.log('🔻 Server shutting down...');
+ p.kill('SIGINT');
+ process.exit(0);
+ });
+
+ process.on('SIGTERM', () => {
+ console.log('🔻 SIGTERM received');
+ p.kill('SIGTERM');
+ process.exit(0);
+ });
+ process.on('exit', () => {
+ p.kill('SIGHUP'); //this one
+ p.kill('SIGTERM');
+ });
+ return p;
+ }
+
+
+ createVideoTrack = (ffmpegProcess: ChildProcessWithoutNullStreams) => {
+ let videoBuffer = Buffer.alloc(0);
+ const videoSource = new nonstandard.RTCVideoSource();
+ const videoStream = ffmpegProcess.stdio[3]; // pipe:3
+ // Start FFmpeg and pipe video frames to the source
+ videoStream.on('data', (chunk: Buffer) => {
+ videoBuffer = Buffer.concat([videoBuffer, chunk]);
+ if (videoBuffer.length > FRAME_SIZE * 2) {
+ console.warn('Video buffer overrun — possible freeze trigger');
+ }
+ while (videoBuffer.length >= FRAME_SIZE) {
+ const frameData = videoBuffer.slice(0, FRAME_SIZE);
+ videoBuffer = videoBuffer.slice(FRAME_SIZE);
+ const frame: nonstandard.RTCVideoFrame = {
+ width: WIDTH,
+ height: HEIGHT,
+ data: new Uint8Array(frameData),
+ }
+ videoSource.onFrame(frame);
+ }
+ });
+
+ videoStream.on('exit', (code) => {
+ console.log(`FFmpeg exited with code ${code}`);
+ });
+ return videoSource.createTrack();
+
+ }
+
+ createAudioTrack = (ffmpegProcess: ChildProcessWithoutNullStreams) => {
+ let audioBuffer = Buffer.alloc(0);
+ const audioSource = new nonstandard.RTCAudioSource();
+ const audioStream = ffmpegProcess.stdio[4]; // pipe:4
+ // --- AUDIO handling ---
+ const AUDIO_FRAME_SIZE = 480 * 2; // 480 samples * 2 bytes (s16le)
+
+ /*
+ audioStream.on('data', (chunk: Buffer) => {
+ audioBuffer = Buffer.concat([audioBuffer, chunk]);
+ while (audioBuffer.length >= AUDIO_FRAME_SIZE) {
+ const frameData = audioBuffer.slice(0, AUDIO_FRAME_SIZE);
+ audioBuffer = audioBuffer.slice(AUDIO_FRAME_SIZE);
+ const samples = new Int16Array(480);
+ for (let i = 0; i < 480; i++) {
+ samples[i] = frameData.readInt16LE(i * 2);
+ }
+ audioSource.onData({
+ samples,
+ sampleRate: 48000,
+ bitsPerSample: 16,
+ channelCount: 1,
+ numberOfFrames: 480
+ });
+ }
+ });
+
+ audioStream.on('data', (data: Buffer) => {
+ // console.error('FFmpeg Error:', data.toString());
+ });
+
+ audioStream.on('exit', (code) => {
+ console.log(`FFmpeg exited with code ${code}`);
+ });
+ */
+ return audioSource.createTrack();
+ }
+
+ createDataChannel = (peerConnection: RTCPeerConnection, getSensors: () => any) => {
+ const dataChannel = peerConnection.createDataChannel('sensors')
+ console.log("create data channel");
+ dataChannel.onopen = () => {
+ console.log('✅ Data channel is open');
+ // Send dummy JSON for testing
+ setInterval(() => {
+ const sensorData = getSensors(); // Assuming getSensors returns JSON
+ dataChannel.send(JSON.stringify(sensorData));
+ }, 1000);
+ };
+
+ dataChannel.onerror = (error) => {
+ console.error('❌ DataChannel error:', error);
+ };
+
+
+ dataChannel.onclose = () => {
+ console.log('❎ DataChannel closed');
+ };
+ return peerConnection;
+ }
+
+ createPeerConnection = (videoTrack: MediaStreamTrack, audioTrack: MediaStreamTrack): RTCPeerConnection => {
+ const peerConnection = new RTCPeerConnection({
+ iceServers: [
+ // {
+ // urls: 'stun:192.168.0.3:3478'
+ // },
+ // {
+ // urls: 'turn:192.168.0.3:3478?transport=udp',
+ // username: 'webrtcuser',
+ // credential: 'webrtccred'
+ // }
+ ]
+ });
+ const stream = new MediaStream()
+ stream.addTrack(videoTrack)
+ stream.addTrack(audioTrack);
+ peerConnection.addTrack(videoTrack, stream);
+ peerConnection.addTrack(audioTrack, stream);
+ return peerConnection;
+ }
+
+}
+
+
+
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..76fc191
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,116 @@
+{
+ "compilerOptions": {
+ /* Visit https://aka.ms/tsconfig to read more about this file */
+ /* Projects */
+ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
+ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
+ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
+ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
+ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
+ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
+ /* Language and Environment */
+ "target": "ES2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
+ "lib": [
+ "esnext",
+ "ES2020",
+ "DOM"
+ ], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+ // "jsx": "preserve", /* Specify what JSX code is generated. */
+ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
+ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
+ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
+ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
+ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
+ "noLib": false, /* Disable including any library files, including the default lib.d.ts. */
+ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
+ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
+ /* Modules */
+ "module": "commonjs", /* Specify what module code is generated. */
+ "rootDir": "src", /* Specify the root folder within your source files. */
+ "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
+ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
+ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
+ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
+ // "types": ["node", "webrtc"],
+ // "typeRoots": ["./node_modules/@types"],
+ // "typeRoots": ["./node_modules/@types/webrtc"], /* Specify multiple folders that act like './node_modules/@types'. */
+ // "types": [], /* Specify type package names to be included without being referenced in a source file. */
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
+ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
+ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
+ // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
+ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
+ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
+ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
+ // "noUncheckedSideEffectImports": true, /* Check side effect imports. */
+ "resolveJsonModule": true, /* Enable importing .json files. */
+ // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
+ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
+ /* JavaScript Support */
+ // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
+ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
+ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
+ /* Emit */
+ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
+ // "declarationMap": true, /* Create sourcemaps for d.ts files. */
+ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
+ "sourceMap": true, /* Create source map files for emitted JavaScript files. */
+ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
+ // "noEmit": true, /* Disable emitting files from a compilation. */
+ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
+ "outDir": "dist", /* Specify an output folder for all emitted files. */
+ // "removeComments": true, /* Disable emitting comments. */
+ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
+ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
+ // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+ // "newLine": "crlf", /* Set the newline character for emitting files. */
+ // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
+ // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
+ "noEmitOnError": false, /* Disable emitting files if any type checking errors are reported. */
+ // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
+ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
+ /* Interop Constraints */
+ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
+ // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
+ // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
+ "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
+ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
+ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
+ /* Type Checking */
+ "strict": false, /* Enable all strict type-checking options. */
+ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
+ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
+ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
+ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
+ // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
+ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
+ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
+ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
+ // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
+ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
+ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
+ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
+ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
+ // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
+ // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
+ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
+ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
+ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
+ /* Completeness */
+ "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
+ "skipLibCheck": true /* Skip type checking all .d.ts files. */
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": [
+ "node_modules","./node_modules/@roamhq/wrtc"
+ ]
+ // "include": [
+ // "./src", "./types"
+ // ]
+}
\ No newline at end of file