Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2be22da2ff | ||
|
|
c3b7e018fb | ||
|
|
9ee09afa77 | ||
|
|
3b993894e4 | ||
|
|
99a39e5d08 | ||
|
|
1b9be90018 | ||
|
|
2f6a271098 | ||
|
|
f91f9ba007 | ||
|
|
aa1fa581ca | ||
|
|
2107a3a071 | ||
|
|
272d225ca8 |
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(wc -l /Users/arthurbarre/dev/perso/ti-pote/apps/robot-client/src/**/*.ts)",
|
||||||
|
"Bash(ssh tipote@192.168.1.124 \"ps aux | grep node | grep -v grep; echo '---LOGS---'; cat /tmp/tipote.log\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
139
.gitea/workflows/build-sd-image.yml
Normal file
139
.gitea/workflows/build-sd-image.yml
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
name: Build Ti-Pote SD Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-image:
|
||||||
|
name: Build Raspberry Pi SD Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 180
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm@latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build robot-client
|
||||||
|
run: pnpm --filter @ti-pote/robot-client build
|
||||||
|
|
||||||
|
- name: Prepare pi-gen stage files
|
||||||
|
run: |
|
||||||
|
STAGE_FILES="tools/pi-gen-tipote/stage3/01-install-tipote/files"
|
||||||
|
rm -rf "$STAGE_FILES"
|
||||||
|
mkdir -p "$STAGE_FILES"
|
||||||
|
|
||||||
|
cp -r apps/robot-client/dist "$STAGE_FILES/dist"
|
||||||
|
cp apps/robot-client/package.json "$STAGE_FILES/package.json"
|
||||||
|
cp apps/robot-client/deploy/tipote.service "$STAGE_FILES/tipote.service"
|
||||||
|
cp apps/robot-client/deploy/journald-tipote.conf "$STAGE_FILES/journald-tipote.conf"
|
||||||
|
|
||||||
|
- name: Register QEMU for ARM emulation
|
||||||
|
run: docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||||
|
|
||||||
|
- name: Build pi-gen Docker image
|
||||||
|
run: docker build -t tipote-builder tools/pi-gen-tipote/
|
||||||
|
|
||||||
|
- name: Build SD image in Docker
|
||||||
|
run: |
|
||||||
|
# Clean up any leftover container from a previous failed build
|
||||||
|
docker rm -f tipote-build 2>/dev/null || true
|
||||||
|
|
||||||
|
# Run pi-gen build inside a privileged container
|
||||||
|
# Use docker cp (not volume mounts) because of DooD path mapping
|
||||||
|
docker run --privileged --name tipote-build tipote-builder
|
||||||
|
|
||||||
|
# Extract the output image
|
||||||
|
mkdir -p output
|
||||||
|
docker cp tipote-build:/output/. output/ 2>/dev/null || true
|
||||||
|
|
||||||
|
# Fallback: check pi-gen deploy dir directly
|
||||||
|
if [ -z "$(ls output/*.img* 2>/dev/null)" ]; then
|
||||||
|
docker cp tipote-build:/build/pi-gen/deploy/. output/ 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker rm tipote-build
|
||||||
|
|
||||||
|
echo "=== Build output ==="
|
||||||
|
ls -lh output/ || echo "No output found"
|
||||||
|
|
||||||
|
- name: Determine version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||||
|
else
|
||||||
|
VERSION="dev-$(echo $GITHUB_SHA | cut -c1-7)"
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Prepare final image
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
|
IMG_FILE=$(find output -name "*.img.xz" -o -name "*.img" 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -z "$IMG_FILE" ]; then
|
||||||
|
echo "ERROR: No image found in output/"
|
||||||
|
ls -laR output/ || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASENAME="tipote-${VERSION}.img"
|
||||||
|
|
||||||
|
if [[ "$IMG_FILE" == *.img.xz ]]; then
|
||||||
|
mv "$IMG_FILE" "output/${BASENAME}.xz"
|
||||||
|
else
|
||||||
|
mv "$IMG_FILE" "output/$BASENAME"
|
||||||
|
xz -T0 -6 "output/$BASENAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ls -lh output/
|
||||||
|
echo "IMAGE_FILE=${BASENAME}.xz" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Upload to Gitea release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
REPO="${{ github.repository }}"
|
||||||
|
|
||||||
|
# Create release
|
||||||
|
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${REPO}/releases" \
|
||||||
|
-H "Authorization: token ${{ secrets.REGISTRY_PASSWORD }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"v${VERSION}\", \"name\": \"Ti-Pote v${VERSION}\", \"body\": \"SD image for Raspberry Pi Zero 2W\"}" \
|
||||||
|
-o /tmp/release.json
|
||||||
|
|
||||||
|
RELEASE_ID=$(jq -r '.id' /tmp/release.json)
|
||||||
|
echo "Release ID: $RELEASE_ID"
|
||||||
|
|
||||||
|
# Upload image as attachment
|
||||||
|
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${IMAGE_FILE}" \
|
||||||
|
-H "Authorization: token ${{ secrets.REGISTRY_PASSWORD }}" \
|
||||||
|
-F "attachment=@output/${IMAGE_FILE}" \
|
||||||
|
-o /tmp/asset.json
|
||||||
|
|
||||||
|
echo "Download URL: $(jq -r '.browser_download_url' /tmp/asset.json)"
|
||||||
|
|
||||||
|
- name: Summary (dev builds)
|
||||||
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
run: |
|
||||||
|
echo "=== SD Image built successfully ==="
|
||||||
|
echo "File: output/${IMAGE_FILE}"
|
||||||
|
ls -lh output/
|
||||||
|
echo ""
|
||||||
|
echo "To create a release, push a tag: git tag v0.1.0 && git push origin v0.1.0"
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -5,6 +5,9 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
.next/
|
.next/
|
||||||
|
*.tsbuildinfo
|
||||||
|
vite.config.js
|
||||||
|
vite.config.d.ts
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
@ -48,3 +51,7 @@ pi-snapshot/
|
|||||||
|
|
||||||
|
|
||||||
.pio/
|
.pio/
|
||||||
|
|
||||||
|
# Pi-gen
|
||||||
|
tools/pi-gen-tipote/output/
|
||||||
|
tools/pi-gen-tipote/pi-gen/
|
||||||
@ -22,13 +22,15 @@
|
|||||||
"migration:revert": "pnpm typeorm migration:revert -d src/config/typeorm.config.ts"
|
"migration:revert": "pnpm typeorm migration:revert -d src/config/typeorm.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/mistral": "^3.0.30",
|
||||||
"@anthropic-ai/sdk": "^0.80.0",
|
"@anthropic-ai/sdk": "^0.80.0",
|
||||||
"@deepgram/sdk": "^5.0.0",
|
"@deepgram/sdk": "^5.0.0",
|
||||||
"@mastra/core": "^1.17.0",
|
"@mastra/core": "^1.17.0",
|
||||||
|
"@mistralai/mistralai": "^2.2.0",
|
||||||
"@nestjs/common": "^11.1.17",
|
"@nestjs/common": "^11.1.17",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/event-emitter": "^3.0.0",
|
|
||||||
"@nestjs/core": "^11.1.17",
|
"@nestjs/core": "^11.1.17",
|
||||||
|
"@nestjs/event-emitter": "^3.0.0",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.1.17",
|
"@nestjs/platform-express": "^11.1.17",
|
||||||
|
|||||||
204
apps/backend/src/adapters/inbound/websocket/app.gateway.ts
Normal file
204
apps/backend/src/adapters/inbound/websocket/app.gateway.ts
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import {
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
SubscribeMessage,
|
||||||
|
MessageBody,
|
||||||
|
ConnectedSocket,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Inject, Logger, forwardRef } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { JwtPayload } from '../rest/auth/strategies/jwt.strategy';
|
||||||
|
import { RobotGateway } from './robot.gateway';
|
||||||
|
import { DeviceService } from '../../../core/services/device.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket gateway for the desktop/web app.
|
||||||
|
*
|
||||||
|
* Authenticates users via JWT (same tokens as REST API).
|
||||||
|
* Broadcasts real-time events:
|
||||||
|
* - `health_report` — device health telemetry
|
||||||
|
* - `device_status` — device online/offline changes
|
||||||
|
* - `device_log` — live log entries
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface AppSocket extends Socket {
|
||||||
|
data: {
|
||||||
|
userId: string;
|
||||||
|
homeId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
namespace: '/ws/app',
|
||||||
|
cors: { origin: '*' },
|
||||||
|
})
|
||||||
|
export class AppGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server!: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(AppGateway.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
@Inject(forwardRef(() => RobotGateway))
|
||||||
|
private readonly robotGateway: RobotGateway,
|
||||||
|
private readonly deviceService: DeviceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Connection lifecycle ──
|
||||||
|
|
||||||
|
async handleConnection(client: AppSocket) {
|
||||||
|
try {
|
||||||
|
const token =
|
||||||
|
client.handshake.auth?.token ||
|
||||||
|
client.handshake.headers?.authorization?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.logger.warn('App WS rejected: no token');
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = this.jwtService.verify<JwtPayload>(token);
|
||||||
|
if (payload.type !== 'user') {
|
||||||
|
this.logger.warn('App WS rejected: not a user token');
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.data.userId = payload.sub;
|
||||||
|
client.data.homeId = payload.homeId;
|
||||||
|
|
||||||
|
// Join a room scoped to the user's home so we can broadcast per-home
|
||||||
|
await client.join(`home:${payload.homeId}`);
|
||||||
|
|
||||||
|
this.logger.log(`App client connected: user ${payload.sub} (home ${payload.homeId})`);
|
||||||
|
} catch {
|
||||||
|
this.logger.warn('App WS rejected: invalid token');
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: AppSocket) {
|
||||||
|
const userId = client.data?.userId;
|
||||||
|
if (userId) {
|
||||||
|
this.logger.log(`App client disconnected: user ${userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subscribe to device for targeted updates ──
|
||||||
|
|
||||||
|
@SubscribeMessage('subscribe_device')
|
||||||
|
handleSubscribeDevice(
|
||||||
|
@ConnectedSocket() client: AppSocket,
|
||||||
|
@MessageBody() data: { deviceId: string },
|
||||||
|
) {
|
||||||
|
void client.join(`device:${data.deviceId}`);
|
||||||
|
this.logger.debug(`User ${client.data.userId} subscribed to device ${data.deviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('unsubscribe_device')
|
||||||
|
handleUnsubscribeDevice(
|
||||||
|
@ConnectedSocket() client: AppSocket,
|
||||||
|
@MessageBody() data: { deviceId: string },
|
||||||
|
) {
|
||||||
|
void client.leave(`device:${data.deviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commands from desktop app → robot ──
|
||||||
|
|
||||||
|
@SubscribeMessage('trigger_conversation')
|
||||||
|
async handleTriggerConversation(
|
||||||
|
@ConnectedSocket() client: AppSocket,
|
||||||
|
@MessageBody() data: { deviceId: string },
|
||||||
|
) {
|
||||||
|
// Verify the user owns this device
|
||||||
|
const device = await this.deviceService.findById(data.deviceId);
|
||||||
|
if (!device || device.homeId !== client.data.homeId) {
|
||||||
|
return { error: 'Device not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = this.robotGateway.sendRemoteTrigger(data.deviceId);
|
||||||
|
if (!sent) {
|
||||||
|
return { error: 'Device not connected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`User ${client.data.userId} triggered conversation on ${data.deviceId}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('set_trigger_mode')
|
||||||
|
async handleSetTriggerMode(
|
||||||
|
@ConnectedSocket() client: AppSocket,
|
||||||
|
@MessageBody() data: { deviceId: string; mode: string },
|
||||||
|
) {
|
||||||
|
if (!['wakeword', 'keyboard'].includes(data.mode)) {
|
||||||
|
return { error: 'Invalid mode. Must be "wakeword" or "keyboard".' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await this.deviceService.findById(data.deviceId);
|
||||||
|
if (!device || device.homeId !== client.data.homeId) {
|
||||||
|
return { error: 'Device not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = this.robotGateway.sendSetTriggerMode(data.deviceId, data.mode);
|
||||||
|
if (!sent) {
|
||||||
|
return { error: 'Device not connected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast the mode change to all subscribed app clients
|
||||||
|
this.server.to(`device:${data.deviceId}`).emit('trigger_mode_changed', {
|
||||||
|
deviceId: data.deviceId,
|
||||||
|
mode: data.mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`User ${client.data.userId} set trigger mode to ${data.mode} on ${data.deviceId}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event listeners (from NestJS EventEmitter) ──
|
||||||
|
|
||||||
|
@OnEvent('device.health_report')
|
||||||
|
handleHealthReportEvent(payload: {
|
||||||
|
deviceId: string;
|
||||||
|
homeId: string;
|
||||||
|
report: Record<string, unknown>;
|
||||||
|
alerts: string[];
|
||||||
|
}) {
|
||||||
|
// Broadcast to all app clients subscribed to this device
|
||||||
|
this.server.to(`device:${payload.deviceId}`).emit('health_report', {
|
||||||
|
deviceId: payload.deviceId,
|
||||||
|
report: payload.report,
|
||||||
|
alerts: payload.alerts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('device.status_changed')
|
||||||
|
handleDeviceStatusEvent(payload: {
|
||||||
|
deviceId: string;
|
||||||
|
homeId: string;
|
||||||
|
status: string;
|
||||||
|
}) {
|
||||||
|
// Broadcast to the entire home (all clients see device go online/offline)
|
||||||
|
this.server.to(`home:${payload.homeId}`).emit('device_status', {
|
||||||
|
deviceId: payload.deviceId,
|
||||||
|
status: payload.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('device.log')
|
||||||
|
handleDeviceLogEvent(payload: {
|
||||||
|
deviceId: string;
|
||||||
|
level: number;
|
||||||
|
msg: string;
|
||||||
|
loggerName: string | null;
|
||||||
|
context: Record<string, unknown> | null;
|
||||||
|
loggedAt: string;
|
||||||
|
}) {
|
||||||
|
this.server.to(`device:${payload.deviceId}`).emit('device_log', payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -175,4 +175,33 @@ export class RobotGateway implements OnGatewayConnection, OnGatewayDisconnect, I
|
|||||||
isDeviceConnected(deviceId: string): boolean {
|
isDeviceConnected(deviceId: string): boolean {
|
||||||
return this.connectedDevices.has(deviceId);
|
return this.connectedDevices.has(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a remote trigger to a connected device (starts a conversation).
|
||||||
|
*/
|
||||||
|
sendRemoteTrigger(deviceId: string): boolean {
|
||||||
|
const socket = this.connectedDevices.get(deviceId);
|
||||||
|
if (!socket) return false;
|
||||||
|
this.logger.log(`Remote trigger sent to device ${deviceId}`);
|
||||||
|
socket.emit('remote_trigger');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a trigger mode change to a connected device.
|
||||||
|
*/
|
||||||
|
sendSetTriggerMode(deviceId: string, mode: string): boolean {
|
||||||
|
const socket = this.connectedDevices.get(deviceId);
|
||||||
|
if (!socket) return false;
|
||||||
|
this.logger.log(`Set trigger mode to ${mode} on device ${deviceId}`);
|
||||||
|
socket.emit('set_trigger_mode', { mode });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the homeId for a connected device.
|
||||||
|
*/
|
||||||
|
getDeviceHomeId(deviceId: string): string | null {
|
||||||
|
return this.connectedDevices.get(deviceId)?.data.homeId ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,11 +16,8 @@ export class AnthropicAdapter implements ILLMPort {
|
|||||||
private readonly model: string;
|
private readonly model: string;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
const apiKey = this.configService.get<string>('ANTHROPIC_API_KEY');
|
const apiKey = this.configService.get<string>('ANTHROPIC_API_KEY', '');
|
||||||
if (!apiKey) {
|
this.client = new Anthropic({ apiKey: apiKey || 'unused' });
|
||||||
throw new Error('ANTHROPIC_API_KEY is not set');
|
|
||||||
}
|
|
||||||
this.client = new Anthropic({ apiKey });
|
|
||||||
this.model = this.configService.get<string>('ANTHROPIC_MODEL', 'claude-sonnet-4-20250514');
|
this.model = this.configService.get<string>('ANTHROPIC_MODEL', 'claude-sonnet-4-20250514');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,11 +16,8 @@ export class OpenAIAdapter implements ILLMPort {
|
|||||||
private readonly model: string;
|
private readonly model: string;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
const apiKey = this.configService.get<string>('OPENAI_API_KEY');
|
const apiKey = this.configService.get<string>('OPENAI_API_KEY', '');
|
||||||
if (!apiKey) {
|
this.client = new OpenAI({ apiKey: apiKey || 'unused' });
|
||||||
throw new Error('OPENAI_API_KEY is not set');
|
|
||||||
}
|
|
||||||
this.client = new OpenAI({ apiKey });
|
|
||||||
this.model = this.configService.get<string>('OPENAI_MODEL', 'gpt-4o');
|
this.model = this.configService.get<string>('OPENAI_MODEL', 'gpt-4o');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,11 +10,8 @@ export class ElevenLabsAdapter implements ITTSPort {
|
|||||||
private readonly defaultVoiceId: string;
|
private readonly defaultVoiceId: string;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
const apiKey = this.configService.get<string>('ELEVENLABS_API_KEY');
|
const apiKey = this.configService.get<string>('ELEVENLABS_API_KEY', '');
|
||||||
if (!apiKey) {
|
this.client = new ElevenLabsClient({ apiKey: apiKey || 'unused' });
|
||||||
throw new Error('ELEVENLABS_API_KEY is not set');
|
|
||||||
}
|
|
||||||
this.client = new ElevenLabsClient({ apiKey });
|
|
||||||
this.defaultVoiceId = this.configService.get<string>('ELEVENLABS_VOICE_ID', 'pFZP5JQG7iQjIQuC4Bku');
|
this.defaultVoiceId = this.configService.get<string>('ELEVENLABS_VOICE_ID', 'pFZP5JQG7iQjIQuC4Bku');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
122
apps/backend/src/adapters/outbound/tts/mistral.adapter.ts
Normal file
122
apps/backend/src/adapters/outbound/tts/mistral.adapter.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Mistral } from '@mistralai/mistralai';
|
||||||
|
import { ITTSPort } from '../../../core/ports/outbound/tts.port';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MistralTTSAdapter implements ITTSPort {
|
||||||
|
private readonly logger = new Logger(MistralTTSAdapter.name);
|
||||||
|
private readonly client: Mistral;
|
||||||
|
private readonly voiceId: string;
|
||||||
|
private readonly model: string;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
const apiKey = this.configService.get<string>('MISTRAL_API_KEY');
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('MISTRAL_API_KEY is not set');
|
||||||
|
}
|
||||||
|
this.client = new Mistral({ apiKey });
|
||||||
|
this.voiceId = this.configService.get<string>('MISTRAL_TTS_VOICE', 'fr_marie_neutral');
|
||||||
|
this.model = this.configService.get<string>('MISTRAL_TTS_MODEL', 'voxtral-mini-tts-2603');
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesize(text: string, voice?: string): Promise<Buffer> {
|
||||||
|
const result = await this.client.audio.speech.complete({
|
||||||
|
model: this.model,
|
||||||
|
input: text,
|
||||||
|
responseFormat: 'wav',
|
||||||
|
stream: false,
|
||||||
|
voiceId: voice || this.voiceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wavBuffer = Buffer.from(result.audioData, 'base64');
|
||||||
|
|
||||||
|
// Extract raw PCM from WAV (skip 44-byte header) and resample to 16kHz
|
||||||
|
// if needed. Voxtral outputs 24kHz by default.
|
||||||
|
const pcm = this.extractPcmFromWav(wavBuffer);
|
||||||
|
return pcm;
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesizeStream(
|
||||||
|
text: string,
|
||||||
|
voice?: string,
|
||||||
|
onChunk?: (chunk: Buffer) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
// Voxtral doesn't support true streaming; synthesize and emit as a single chunk.
|
||||||
|
const pcm = await this.synthesize(text, voice);
|
||||||
|
onChunk?.(pcm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract raw PCM data from a WAV buffer and resample to 16kHz mono S16LE
|
||||||
|
* if the source sample rate differs.
|
||||||
|
*/
|
||||||
|
private extractPcmFromWav(wav: Buffer): Buffer {
|
||||||
|
// Parse WAV header
|
||||||
|
const sampleRate = wav.readUInt32LE(24);
|
||||||
|
const bitsPerSample = wav.readUInt16LE(34);
|
||||||
|
const numChannels = wav.readUInt16LE(22);
|
||||||
|
|
||||||
|
// Find the 'data' chunk
|
||||||
|
let dataOffset = 12;
|
||||||
|
while (dataOffset < wav.length - 8) {
|
||||||
|
const chunkId = wav.toString('ascii', dataOffset, dataOffset + 4);
|
||||||
|
const chunkSize = wav.readUInt32LE(dataOffset + 4);
|
||||||
|
if (chunkId === 'data') {
|
||||||
|
dataOffset += 8;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
dataOffset += 8 + chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pcm = wav.subarray(dataOffset);
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`WAV: ${sampleRate}Hz, ${bitsPerSample}bit, ${numChannels}ch, ${pcm.length} bytes PCM`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert to mono if stereo
|
||||||
|
if (numChannels === 2 && bitsPerSample === 16) {
|
||||||
|
const monoSamples = pcm.length / 4;
|
||||||
|
const mono = Buffer.alloc(monoSamples * 2);
|
||||||
|
for (let i = 0; i < monoSamples; i++) {
|
||||||
|
const left = pcm.readInt16LE(i * 4);
|
||||||
|
const right = pcm.readInt16LE(i * 4 + 2);
|
||||||
|
mono.writeInt16LE(Math.round((left + right) / 2), i * 2);
|
||||||
|
}
|
||||||
|
pcm = mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resample to 16kHz if needed (simple linear interpolation)
|
||||||
|
if (sampleRate !== 16000) {
|
||||||
|
pcm = this.resample(pcm, sampleRate, 16000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pcm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple linear-interpolation resampler for 16-bit mono PCM.
|
||||||
|
*/
|
||||||
|
private resample(pcm: Buffer, fromRate: number, toRate: number): Buffer {
|
||||||
|
const ratio = fromRate / toRate;
|
||||||
|
const srcSamples = pcm.length / 2;
|
||||||
|
const dstSamples = Math.floor(srcSamples / ratio);
|
||||||
|
const out = Buffer.alloc(dstSamples * 2);
|
||||||
|
|
||||||
|
for (let i = 0; i < dstSamples; i++) {
|
||||||
|
const srcPos = i * ratio;
|
||||||
|
const srcIdx = Math.floor(srcPos);
|
||||||
|
const frac = srcPos - srcIdx;
|
||||||
|
|
||||||
|
const s0 = pcm.readInt16LE(Math.min(srcIdx, srcSamples - 1) * 2);
|
||||||
|
const s1 = pcm.readInt16LE(Math.min(srcIdx + 1, srcSamples - 1) * 2);
|
||||||
|
const sample = Math.round(s0 + frac * (s1 - s0));
|
||||||
|
|
||||||
|
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Resampled ${fromRate}→${toRate}Hz: ${srcSamples}→${dstSamples} samples`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,10 +27,12 @@ import { HealthController } from './adapters/inbound/rest/health/health.controll
|
|||||||
import { LogsController } from './adapters/inbound/rest/logs/logs.controller';
|
import { LogsController } from './adapters/inbound/rest/logs/logs.controller';
|
||||||
import { PairingService } from './core/services/pairing.service';
|
import { PairingService } from './core/services/pairing.service';
|
||||||
import { RobotGateway } from './adapters/inbound/websocket/robot.gateway';
|
import { RobotGateway } from './adapters/inbound/websocket/robot.gateway';
|
||||||
|
import { AppGateway } from './adapters/inbound/websocket/app.gateway';
|
||||||
import { DeepgramAdapter } from './adapters/outbound/stt/deepgram.adapter';
|
import { DeepgramAdapter } from './adapters/outbound/stt/deepgram.adapter';
|
||||||
import { AnthropicAdapter } from './adapters/outbound/llm/anthropic.adapter';
|
import { AnthropicAdapter } from './adapters/outbound/llm/anthropic.adapter';
|
||||||
import { OpenAIAdapter } from './adapters/outbound/llm/openai.adapter';
|
import { OpenAIAdapter } from './adapters/outbound/llm/openai.adapter';
|
||||||
import { ElevenLabsAdapter } from './adapters/outbound/tts/elevenlabs.adapter';
|
import { ElevenLabsAdapter } from './adapters/outbound/tts/elevenlabs.adapter';
|
||||||
|
import { MistralTTSAdapter } from './adapters/outbound/tts/mistral.adapter';
|
||||||
import { RedisAdapter } from './adapters/outbound/cache/redis.adapter';
|
import { RedisAdapter } from './adapters/outbound/cache/redis.adapter';
|
||||||
import { CONVERSATION_PORT } from './core/ports/inbound/conversation.port';
|
import { CONVERSATION_PORT } from './core/ports/inbound/conversation.port';
|
||||||
import { HEALTH_TELEMETRY_PORT } from './core/ports/inbound/health-telemetry.port';
|
import { HEALTH_TELEMETRY_PORT } from './core/ports/inbound/health-telemetry.port';
|
||||||
@ -75,6 +77,7 @@ import { CACHE_PORT } from './core/ports/outbound/cache.port';
|
|||||||
PairingService,
|
PairingService,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
RobotGateway,
|
RobotGateway,
|
||||||
|
AppGateway,
|
||||||
HealthTelemetryService,
|
HealthTelemetryService,
|
||||||
LogIngestionService,
|
LogIngestionService,
|
||||||
{
|
{
|
||||||
@ -106,7 +109,14 @@ import { CACHE_PORT } from './core/ports/outbound/cache.port';
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: TTS_PORT,
|
provide: TTS_PORT,
|
||||||
useClass: ElevenLabsAdapter,
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => {
|
||||||
|
const provider = configService.get<string>('TTS_PROVIDER', 'elevenlabs');
|
||||||
|
if (provider === 'mistral') {
|
||||||
|
return new MistralTTSAdapter(configService);
|
||||||
|
}
|
||||||
|
return new ElevenLabsAdapter(configService);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: CACHE_PORT,
|
provide: CACHE_PORT,
|
||||||
|
|||||||
@ -45,10 +45,18 @@ export class ConversationService implements IConversationPort {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
const provider = this.configService.get<string>('LLM_PROVIDER', 'anthropic');
|
const provider = this.configService.get<string>('LLM_PROVIDER', 'anthropic');
|
||||||
const model =
|
let model: string;
|
||||||
provider === 'openai'
|
switch (provider) {
|
||||||
? `openai/${this.configService.get<string>('OPENAI_MODEL', 'gpt-4o')}`
|
case 'openai':
|
||||||
: `anthropic/${this.configService.get<string>('ANTHROPIC_MODEL', 'claude-sonnet-4-20250514')}`;
|
model = `openai/${this.configService.get<string>('OPENAI_MODEL', 'gpt-4o')}`;
|
||||||
|
break;
|
||||||
|
case 'mistral':
|
||||||
|
model = `mistral/${this.configService.get<string>('MISTRAL_MODEL', 'ministral-3b-latest')}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
model = `anthropic/${this.configService.get<string>('ANTHROPIC_MODEL', 'claude-sonnet-4-20250514')}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
this.agent = new Agent({
|
this.agent = new Agent({
|
||||||
id: 'ti-pote',
|
id: 'ti-pote',
|
||||||
@ -97,7 +105,7 @@ export class ConversationService implements IConversationPort {
|
|||||||
this.activeSessions.set(deviceId, session);
|
this.activeSessions.set(deviceId, session);
|
||||||
|
|
||||||
const sttStream = await this.sttPort.openStream((result: TranscriptionResult) => {
|
const sttStream = await this.sttPort.openStream((result: TranscriptionResult) => {
|
||||||
this.logger.debug(
|
this.logger.log(
|
||||||
`STT [${deviceId}]: "${result.text}" (final: ${result.isFinal}, confidence: ${result.confidence})`,
|
`STT [${deviceId}]: "${result.text}" (final: ${result.isFinal}, confidence: ${result.confidence})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -156,6 +164,9 @@ export class ConversationService implements IConversationPort {
|
|||||||
this.logger.log(`Final transcription for ${deviceId}: "${finalText}"`);
|
this.logger.log(`Final transcription for ${deviceId}: "${finalText}"`);
|
||||||
|
|
||||||
if (!finalText) {
|
if (!finalText) {
|
||||||
|
this.logger.warn(`No transcription for ${deviceId} — returning to idle`);
|
||||||
|
this.deviceGateway.sendStatus(deviceId, 'idle');
|
||||||
|
this.activeSessions.delete(deviceId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { Device, DeviceStatus } from '../domain/entities/device.entity';
|
import { Device, DeviceStatus } from '../domain/entities/device.entity';
|
||||||
|
|
||||||
@ -9,6 +10,7 @@ export class DeviceService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Device)
|
@InjectRepository(Device)
|
||||||
private readonly deviceRepository: Repository<Device>,
|
private readonly deviceRepository: Repository<Device>,
|
||||||
|
private readonly events: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(id: string): Promise<Device | null> {
|
async findById(id: string): Promise<Device | null> {
|
||||||
@ -43,5 +45,15 @@ export class DeviceService {
|
|||||||
status,
|
status,
|
||||||
lastSeenAt: new Date(),
|
lastSeenAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Broadcast status change to app clients
|
||||||
|
const device = await this.deviceRepository.findOne({ where: { id }, select: ['homeId'] });
|
||||||
|
if (device) {
|
||||||
|
this.events.emit('device.status_changed', {
|
||||||
|
deviceId: id,
|
||||||
|
homeId: device.homeId,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { HealthReport } from '../domain/entities/health-report.entity';
|
import { HealthReport } from '../domain/entities/health-report.entity';
|
||||||
|
import { Device } from '../domain/entities/device.entity';
|
||||||
import {
|
import {
|
||||||
IHealthTelemetryPort,
|
IHealthTelemetryPort,
|
||||||
HealthReportPayload,
|
HealthReportPayload,
|
||||||
@ -23,6 +25,9 @@ export class HealthTelemetryService implements IHealthTelemetryPort {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(HealthReport)
|
@InjectRepository(HealthReport)
|
||||||
private readonly repo: Repository<HealthReport>,
|
private readonly repo: Repository<HealthReport>,
|
||||||
|
@InjectRepository(Device)
|
||||||
|
private readonly deviceRepo: Repository<Device>,
|
||||||
|
private readonly events: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ingestReport(deviceId: string, payload: HealthReportPayload): Promise<void> {
|
async ingestReport(deviceId: string, payload: HealthReportPayload): Promise<void> {
|
||||||
@ -52,6 +57,17 @@ export class HealthTelemetryService implements IHealthTelemetryPort {
|
|||||||
} else {
|
} else {
|
||||||
this.logger.debug({ deviceId }, 'Health report ingested');
|
this.logger.debug({ deviceId }, 'Health report ingested');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast to connected app clients via EventEmitter → AppGateway
|
||||||
|
const device = await this.deviceRepo.findOne({ where: { id: deviceId }, select: ['homeId'] });
|
||||||
|
if (device) {
|
||||||
|
this.events.emit('device.health_report', {
|
||||||
|
deviceId,
|
||||||
|
homeId: device.homeId,
|
||||||
|
report: payload,
|
||||||
|
alerts,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLatestReports(deviceId: string, limit = 20): Promise<HealthReportPayload[]> {
|
async getLatestReports(deviceId: string, limit = 20): Promise<HealthReportPayload[]> {
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"zustand": "^4.5.5"
|
"zustand": "^4.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
type Me,
|
type Me,
|
||||||
type RegisterInput,
|
type RegisterInput,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
|
import { getSocket, disconnectSocket } from '../lib/socket';
|
||||||
|
|
||||||
type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
||||||
|
|
||||||
@ -46,6 +47,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setUser(me);
|
setUser(me);
|
||||||
setStatus('authenticated');
|
setStatus('authenticated');
|
||||||
|
// Connect WebSocket on session restore
|
||||||
|
getSocket().catch(() => {});
|
||||||
} catch {
|
} catch {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@ -63,6 +66,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const me = await api.me();
|
const me = await api.me();
|
||||||
setUser(me);
|
setUser(me);
|
||||||
setStatus('authenticated');
|
setStatus('authenticated');
|
||||||
|
// Connect WebSocket after login
|
||||||
|
getSocket().catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const register = useCallback(async (input: RegisterInput) => {
|
const register = useCallback(async (input: RegisterInput) => {
|
||||||
@ -73,6 +78,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
|
disconnectSocket();
|
||||||
await api.logout();
|
await api.logout();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setStatus('unauthenticated');
|
setStatus('unauthenticated');
|
||||||
|
|||||||
281
apps/frontend/src/hooks/useDeviceSocket.ts
Normal file
281
apps/frontend/src/hooks/useDeviceSocket.ts
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* React hooks for real-time device updates via WebSocket.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { getSocket } from '../lib/socket';
|
||||||
|
import type { HealthReport, LogEntry } from '../lib/api';
|
||||||
|
import type { Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
export type TriggerMode = 'wakeword' | 'keyboard';
|
||||||
|
|
||||||
|
// ─── Level label mapping (pino levels) ────────────────────────────
|
||||||
|
|
||||||
|
const LEVEL_LABELS: Record<number, string> = {
|
||||||
|
10: 'trace',
|
||||||
|
20: 'debug',
|
||||||
|
30: 'info',
|
||||||
|
40: 'warn',
|
||||||
|
50: 'error',
|
||||||
|
60: 'fatal',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── useDeviceHealth ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to real-time health reports for a device.
|
||||||
|
* Returns the latest report, the history, and current alerts.
|
||||||
|
*/
|
||||||
|
export function useDeviceHealth(deviceId: string | undefined) {
|
||||||
|
const [latest, setLatest] = useState<HealthReport | null>(null);
|
||||||
|
const [reports, setReports] = useState<HealthReport[]>([]);
|
||||||
|
const [alerts, setAlerts] = useState<string[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deviceId) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const sock = await getSocket();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
socketRef.current = sock;
|
||||||
|
setConnected(sock.connected);
|
||||||
|
|
||||||
|
// Subscribe to this device's updates
|
||||||
|
sock.emit('subscribe_device', { deviceId });
|
||||||
|
|
||||||
|
const onHealthReport = (data: {
|
||||||
|
deviceId: string;
|
||||||
|
report: HealthReport;
|
||||||
|
alerts: string[];
|
||||||
|
}) => {
|
||||||
|
if (data.deviceId !== deviceId) return;
|
||||||
|
|
||||||
|
setLatest(data.report);
|
||||||
|
setAlerts(data.alerts);
|
||||||
|
setReports((prev) => {
|
||||||
|
const next = [data.report, ...prev];
|
||||||
|
return next.slice(0, 50); // Keep last 50
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConnect = () => setConnected(true);
|
||||||
|
const onDisconnect = () => setConnected(false);
|
||||||
|
|
||||||
|
sock.on('health_report', onHealthReport);
|
||||||
|
sock.on('connect', onConnect);
|
||||||
|
sock.on('disconnect', onDisconnect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sock.emit('unsubscribe_device', { deviceId });
|
||||||
|
sock.off('health_report', onHealthReport);
|
||||||
|
sock.off('connect', onConnect);
|
||||||
|
sock.off('disconnect', onDisconnect);
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to connect device socket:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.emit('unsubscribe_device', { deviceId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [deviceId]);
|
||||||
|
|
||||||
|
return { latest, reports, alerts, connected };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── useDeviceStatus ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to device status changes (online/offline) for all devices in the home.
|
||||||
|
* Returns a map of deviceId → status.
|
||||||
|
*/
|
||||||
|
export function useDeviceStatus() {
|
||||||
|
const [statuses, setStatuses] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let sock: Socket | null = null;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
sock = await getSocket();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const onStatus = (data: { deviceId: string; status: string }) => {
|
||||||
|
setStatuses((prev) => ({ ...prev, [data.deviceId]: data.status }));
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.on('device_status', onStatus);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sock?.off('device_status', onStatus);
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Not authenticated yet
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── useDeviceLogs ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to real-time log entries for a device.
|
||||||
|
*/
|
||||||
|
export function useDeviceLogs(deviceId: string | undefined, minLevel = 0) {
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
|
||||||
|
const clearLogs = useCallback(() => setLogs([]), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deviceId) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const sock = await getSocket();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
socketRef.current = sock;
|
||||||
|
sock.emit('subscribe_device', { deviceId });
|
||||||
|
|
||||||
|
const onLog = (data: {
|
||||||
|
deviceId: string;
|
||||||
|
level: number;
|
||||||
|
msg: string;
|
||||||
|
loggerName: string | null;
|
||||||
|
context: Record<string, unknown> | null;
|
||||||
|
loggedAt: string;
|
||||||
|
}) => {
|
||||||
|
if (data.deviceId !== deviceId) return;
|
||||||
|
if (data.level < minLevel) return;
|
||||||
|
|
||||||
|
const entry: LogEntry = {
|
||||||
|
level: data.level,
|
||||||
|
levelLabel: LEVEL_LABELS[data.level] ?? 'info',
|
||||||
|
time: new Date(data.loggedAt).getTime(),
|
||||||
|
msg: data.msg,
|
||||||
|
name: data.loggerName ?? undefined,
|
||||||
|
...(data.context ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
setLogs((prev) => {
|
||||||
|
const next = [entry, ...prev];
|
||||||
|
return next.slice(0, 500); // Keep last 500
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.on('device_log', onLog);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sock.emit('unsubscribe_device', { deviceId });
|
||||||
|
sock.off('device_log', onLog);
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Not authenticated
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.emit('unsubscribe_device', { deviceId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [deviceId, minLevel]);
|
||||||
|
|
||||||
|
return { logs, clearLogs };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── useDeviceCommands ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send commands to a device via WebSocket.
|
||||||
|
*/
|
||||||
|
export function useDeviceCommands(deviceId: string | undefined) {
|
||||||
|
const [triggerMode, setTriggerMode] = useState<TriggerMode>('keyboard');
|
||||||
|
const [triggering, setTriggering] = useState(false);
|
||||||
|
|
||||||
|
// Listen for trigger mode changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deviceId) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const sock = await getSocket();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const onModeChanged = (data: { deviceId: string; mode: string }) => {
|
||||||
|
if (data.deviceId !== deviceId) return;
|
||||||
|
setTriggerMode(data.mode as TriggerMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.on('trigger_mode_changed', onModeChanged);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sock.off('trigger_mode_changed', onModeChanged);
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Not connected
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [deviceId]);
|
||||||
|
|
||||||
|
const triggerConversation = useCallback(async () => {
|
||||||
|
if (!deviceId) return;
|
||||||
|
setTriggering(true);
|
||||||
|
try {
|
||||||
|
const sock = await getSocket();
|
||||||
|
sock.emit('trigger_conversation', { deviceId }, (response: { ok?: boolean; error?: string }) => {
|
||||||
|
if (response?.error) {
|
||||||
|
console.warn('Trigger failed:', response.error);
|
||||||
|
}
|
||||||
|
setTriggering(false);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setTriggering(false);
|
||||||
|
}
|
||||||
|
}, [deviceId]);
|
||||||
|
|
||||||
|
const changeTriggerMode = useCallback(async (mode: TriggerMode) => {
|
||||||
|
if (!deviceId) return;
|
||||||
|
try {
|
||||||
|
const sock = await getSocket();
|
||||||
|
sock.emit('set_trigger_mode', { deviceId, mode }, (response: { ok?: boolean; error?: string }) => {
|
||||||
|
if (response?.error) {
|
||||||
|
console.warn('Set trigger mode failed:', response.error);
|
||||||
|
} else {
|
||||||
|
setTriggerMode(mode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Not connected
|
||||||
|
}
|
||||||
|
}, [deviceId]);
|
||||||
|
|
||||||
|
return { triggerMode, triggerConversation, triggering, changeTriggerMode };
|
||||||
|
}
|
||||||
72
apps/frontend/src/lib/socket.ts
Normal file
72
apps/frontend/src/lib/socket.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Singleton Socket.IO client for the app WebSocket (`/ws/app`).
|
||||||
|
*
|
||||||
|
* Provides real-time events from the backend:
|
||||||
|
* - `health_report` — device health telemetry
|
||||||
|
* - `device_status` — device online/offline changes
|
||||||
|
* - `device_log` — live log entries
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { io, type Socket } from 'socket.io-client';
|
||||||
|
import { storage } from './storage';
|
||||||
|
|
||||||
|
const BASE_URL =
|
||||||
|
(import.meta.env.VITE_API_URL as string | undefined)?.replace(/\/$/, '') ||
|
||||||
|
'http://localhost:3000';
|
||||||
|
|
||||||
|
const ACCESS_KEY = 'auth.accessToken';
|
||||||
|
|
||||||
|
let socket: Socket | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the singleton socket connection.
|
||||||
|
* Connects to /ws/app with the current user JWT.
|
||||||
|
*/
|
||||||
|
export async function getSocket(): Promise<Socket> {
|
||||||
|
if (socket?.connected) return socket;
|
||||||
|
|
||||||
|
const token = await storage.get<string>(ACCESS_KEY);
|
||||||
|
if (!token) throw new Error('No auth token for WebSocket');
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
// Reconnect with fresh token
|
||||||
|
socket.auth = { token };
|
||||||
|
socket.connect();
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket = io(`${BASE_URL}/ws/app`, {
|
||||||
|
auth: { token },
|
||||||
|
transports: ['websocket'],
|
||||||
|
autoConnect: true,
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: Infinity,
|
||||||
|
reconnectionDelay: 2000,
|
||||||
|
reconnectionDelayMax: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('[ws/app] connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', (reason) => {
|
||||||
|
console.log('[ws/app] disconnected:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', (err) => {
|
||||||
|
console.warn('[ws/app] connection error:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect and destroy the singleton socket.
|
||||||
|
* Call this on logout.
|
||||||
|
*/
|
||||||
|
export function disconnectSocket(): void {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,9 +3,11 @@ import { Link } from 'react-router-dom';
|
|||||||
import { Button, Card, StatusBadge } from '../components/ui';
|
import { Button, Card, StatusBadge } from '../components/ui';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { api, ApiError, type DeviceSummary } from '../lib/api';
|
import { api, ApiError, type DeviceSummary } from '../lib/api';
|
||||||
|
import { useDeviceStatus } from '../hooks/useDeviceSocket';
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const statusMap = useDeviceStatus();
|
||||||
const [devices, setDevices] = useState<DeviceSummary[] | null>(null);
|
const [devices, setDevices] = useState<DeviceSummary[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -101,7 +103,7 @@ export function DashboardPage() {
|
|||||||
{d.id}
|
{d.id}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={d.status} />
|
<StatusBadge status={(statusMap[d.id] as DeviceSummary['status']) ?? d.status} />
|
||||||
</div>
|
</div>
|
||||||
<dl className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
|
<dl className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
type HealthReport,
|
type HealthReport,
|
||||||
type LogEntry,
|
type LogEntry,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
|
import { useDeviceHealth, useDeviceLogs, useDeviceStatus, useDeviceCommands } from '../hooks/useDeviceSocket';
|
||||||
|
|
||||||
// ─── Level helpers ────────────────────────────────────────────────
|
// ─── Level helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -36,20 +37,50 @@ export function DeviceDetailPage() {
|
|||||||
|
|
||||||
// Device info
|
// Device info
|
||||||
const [device, setDevice] = useState<DeviceSummary | null>(null);
|
const [device, setDevice] = useState<DeviceSummary | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Health
|
// Remote commands
|
||||||
const [reports, setReports] = useState<HealthReport[]>([]);
|
const {
|
||||||
const [alerts, setAlerts] = useState<string[]>([]);
|
triggerMode,
|
||||||
|
triggerConversation,
|
||||||
|
triggering,
|
||||||
|
changeTriggerMode,
|
||||||
|
} = useDeviceCommands(deviceId);
|
||||||
|
|
||||||
|
// Real-time health via WebSocket
|
||||||
|
const {
|
||||||
|
latest: wsLatest,
|
||||||
|
reports: wsReports,
|
||||||
|
alerts: wsAlerts,
|
||||||
|
connected: wsConnected,
|
||||||
|
} = useDeviceHealth(deviceId);
|
||||||
|
|
||||||
|
// Real-time device status via WebSocket
|
||||||
|
const statusMap = useDeviceStatus();
|
||||||
|
const liveStatus = deviceId ? statusMap[deviceId] : undefined;
|
||||||
|
|
||||||
|
// Initial health data loaded from REST (hydration)
|
||||||
|
const [initialReports, setInitialReports] = useState<HealthReport[]>([]);
|
||||||
|
const [initialAlerts, setInitialAlerts] = useState<string[]>([]);
|
||||||
const [healthLoading, setHealthLoading] = useState(true);
|
const [healthLoading, setHealthLoading] = useState(true);
|
||||||
|
|
||||||
// Logs
|
// Merge: WS reports on top of initial REST data
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const reports = wsReports.length > 0 ? wsReports : initialReports;
|
||||||
const [logsTotal, setLogsTotal] = useState(0);
|
const alerts = wsAlerts.length > 0 ? wsAlerts : initialAlerts;
|
||||||
const [logsLoading, setLogsLoading] = useState(false);
|
const latest = wsLatest ?? reports[0] ?? null;
|
||||||
|
|
||||||
|
// Logs — real-time via WebSocket + initial load from REST
|
||||||
const [logLevel, setLogLevel] = useState(0);
|
const [logLevel, setLogLevel] = useState(0);
|
||||||
const [logSearch, setLogSearch] = useState('');
|
const [logSearch, setLogSearch] = useState('');
|
||||||
|
const { logs: wsLogs } = useDeviceLogs(deviceId, logLevel);
|
||||||
|
const [initialLogs, setInitialLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [logsTotal, setLogsTotal] = useState(0);
|
||||||
|
const [logsLoading, setLogsLoading] = useState(false);
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
// Merge: WS logs on top, then initial
|
||||||
|
const logs = [...wsLogs, ...initialLogs.filter(
|
||||||
|
(il) => !wsLogs.some((wl) => wl.time === il.time && wl.msg === il.msg),
|
||||||
|
)];
|
||||||
|
|
||||||
// ── Fetch device info ──
|
// ── Fetch device info ──
|
||||||
|
|
||||||
@ -64,10 +95,20 @@ export function DeviceDetailPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
// ── Fetch health data ──
|
// Update device status from WebSocket
|
||||||
|
useEffect(() => {
|
||||||
|
if (liveStatus && device) {
|
||||||
|
setDevice((d) => d ? { ...d, status: liveStatus as DeviceSummary['status'] } : d);
|
||||||
|
}
|
||||||
|
}, [liveStatus]);
|
||||||
|
|
||||||
const fetchHealth = useCallback(async () => {
|
// ── Hydrate health from REST on first load ──
|
||||||
if (!deviceId) return;
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deviceId || tab !== 'health') return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
setHealthLoading(true);
|
setHealthLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@ -75,20 +116,21 @@ export function DeviceDetailPage() {
|
|||||||
api.getHealthReports(deviceId, 20),
|
api.getHealthReports(deviceId, 20),
|
||||||
api.getHealthAlerts(deviceId),
|
api.getHealthAlerts(deviceId),
|
||||||
]);
|
]);
|
||||||
setReports(reportsRes.reports);
|
if (!cancelled) {
|
||||||
setAlerts(alertsRes.alerts);
|
setInitialReports(reportsRes.reports);
|
||||||
} catch (err) {
|
setInitialAlerts(alertsRes.alerts);
|
||||||
setError(err instanceof ApiError ? err.message : 'Erreur réseau');
|
|
||||||
} finally {
|
|
||||||
setHealthLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [deviceId]);
|
} catch (err) {
|
||||||
|
if (!cancelled) setError(err instanceof ApiError ? err.message : 'Erreur réseau');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setHealthLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
useEffect(() => {
|
return () => { cancelled = true; };
|
||||||
if (tab === 'health') fetchHealth();
|
}, [deviceId, tab]);
|
||||||
}, [tab, fetchHealth]);
|
|
||||||
|
|
||||||
// ── Fetch logs ──
|
// ── Hydrate logs from REST on first load ──
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
const fetchLogs = useCallback(async () => {
|
||||||
if (!deviceId) return;
|
if (!deviceId) return;
|
||||||
@ -99,7 +141,7 @@ export function DeviceDetailPage() {
|
|||||||
search: logSearch || undefined,
|
search: logSearch || undefined,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
setLogs(res.logs);
|
setInitialLogs(res.logs);
|
||||||
setLogsTotal(res.total);
|
setLogsTotal(res.total);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : 'Erreur réseau');
|
setError(err instanceof ApiError ? err.message : 'Erreur réseau');
|
||||||
@ -132,8 +174,6 @@ export function DeviceDetailPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const latest = reports[0] ?? null;
|
|
||||||
|
|
||||||
// ─── Render ─────────────────────────────────────────────────────
|
// ─── Render ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -154,9 +194,64 @@ export function DeviceDetailPage() {
|
|||||||
<p className="font-mono text-xs text-slate-500">{deviceId}</p>
|
<p className="font-mono text-xs text-slate-500">{deviceId}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{wsConnected && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-emerald-400">
|
||||||
|
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
|
||||||
|
live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{device && <StatusBadge status={device.status} />}
|
{device && <StatusBadge status={device.status} />}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* ── Remote controls ── */}
|
||||||
|
{device?.status === 'online' && (
|
||||||
|
<Card className="flex items-center justify-between gap-4 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => void triggerConversation()}
|
||||||
|
loading={triggering}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-lg leading-none">🎙</span>
|
||||||
|
Parler
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Déclenche une conversation à distance
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-slate-400">Mode :</span>
|
||||||
|
<div className="flex overflow-hidden rounded-lg border border-slate-700">
|
||||||
|
<button
|
||||||
|
onClick={() => void changeTriggerMode('keyboard')}
|
||||||
|
className={[
|
||||||
|
'px-3 py-1.5 text-xs font-medium transition-colors',
|
||||||
|
triggerMode === 'keyboard'
|
||||||
|
? 'bg-brand-500/20 text-brand-400'
|
||||||
|
: 'text-slate-400 hover:bg-slate-800',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
Manuel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void changeTriggerMode('wakeword')}
|
||||||
|
className={[
|
||||||
|
'px-3 py-1.5 text-xs font-medium transition-colors border-l border-slate-700',
|
||||||
|
triggerMode === 'wakeword'
|
||||||
|
? 'bg-brand-500/20 text-brand-400'
|
||||||
|
: 'text-slate-400 hover:bg-slate-800',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
Wake word
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 rounded-xl bg-slate-900/50 p-1">
|
<div className="flex gap-1 rounded-xl bg-slate-900/50 p-1">
|
||||||
<button
|
<button
|
||||||
@ -268,9 +363,19 @@ export function DeviceDetailPage() {
|
|||||||
<h3 className="text-sm font-medium text-slate-300">
|
<h3 className="text-sm font-medium text-slate-300">
|
||||||
Historique ({reports.length} rapports)
|
Historique ({reports.length} rapports)
|
||||||
</h3>
|
</h3>
|
||||||
<Button variant="ghost" className="text-xs" onClick={() => void fetchHealth()}>
|
{wsConnected ? (
|
||||||
|
<span className="text-xs text-emerald-400/70">Mise à jour en direct</span>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" className="text-xs" onClick={() => {
|
||||||
|
setHealthLoading(true);
|
||||||
|
api.getHealthReports(deviceId!, 20).then((r) => {
|
||||||
|
setInitialReports(r.reports);
|
||||||
|
setHealthLoading(false);
|
||||||
|
}).catch(() => setHealthLoading(false));
|
||||||
|
}}>
|
||||||
Rafraîchir
|
Rafraîchir
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-48 overflow-y-auto">
|
<div className="max-h-48 overflow-y-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
|
|||||||
@ -522,8 +522,11 @@ export function SetupRobotPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom link */}
|
{/* Bottom links */}
|
||||||
<div className="mt-6 flex items-center justify-center gap-1 text-sm text-slate-400">
|
<div className="mt-6 flex flex-col items-center gap-2 text-sm text-slate-400">
|
||||||
|
<Link to="/pair" className="text-brand-400 hover:text-brand-300">
|
||||||
|
Déjà sur le réseau ? Entrer un code
|
||||||
|
</Link>
|
||||||
<Link to="/" className="hover:text-slate-200">
|
<Link to="/" className="hover:text-slate-200">
|
||||||
← Retour au tableau de bord
|
← Retour au tableau de bord
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
Description=Ti-Pote Robot Client
|
Description=Ti-Pote Robot Client
|
||||||
After=network.target NetworkManager.service
|
After=network.target NetworkManager.service
|
||||||
Wants=NetworkManager.service
|
Wants=NetworkManager.service
|
||||||
|
StartLimitIntervalSec=300
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=notify
|
Type=notify
|
||||||
@ -14,8 +16,6 @@ Group=tipote
|
|||||||
# Restart policy: max 5 restarts per 5 minutes
|
# Restart policy: max 5 restarts per 5 minutes
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StartLimitIntervalSec=300
|
|
||||||
StartLimitBurst=5
|
|
||||||
|
|
||||||
# Watchdog: process must ping every 60s or gets killed
|
# Watchdog: process must ping every 60s or gets killed
|
||||||
WatchdogSec=60
|
WatchdogSec=60
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"serialport": "^12.0.0",
|
"serialport": "^12.0.0",
|
||||||
"sd-notify": "^3.0.2"
|
"sd-notify": "^2.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
|
|||||||
@ -147,8 +147,12 @@ def run_predict_loop(oww_model, read_chunk, state: State, threshold: float):
|
|||||||
# Keep draining but don't emit detections.
|
# Keep draining but don't emit detections.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for _, score in oww_model.prediction_buffer.items():
|
for name, score in oww_model.prediction_buffer.items():
|
||||||
if len(score) > 0 and score[-1] > threshold:
|
if len(score) > 0:
|
||||||
|
s = score[-1]
|
||||||
|
if s > 0.05:
|
||||||
|
print(f"SCORE: {name}={s:.3f}", file=sys.stderr, flush=True)
|
||||||
|
if s > threshold:
|
||||||
print("DETECTED", flush=True)
|
print("DETECTED", flush=True)
|
||||||
oww_model.reset()
|
oww_model.reset()
|
||||||
break
|
break
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export function loadHardwareConfig(): HardwareConfig {
|
|||||||
wakeWord: {
|
wakeWord: {
|
||||||
pythonPath: process.env.WAKEWORD_PYTHON_PATH || 'python3',
|
pythonPath: process.env.WAKEWORD_PYTHON_PATH || 'python3',
|
||||||
scriptPath: process.env.WAKEWORD_SCRIPT_PATH || './scripts/wake_word.py',
|
scriptPath: process.env.WAKEWORD_SCRIPT_PATH || './scripts/wake_word.py',
|
||||||
modelName: process.env.WAKEWORD_MODEL || 'hey_ti_pote',
|
modelName: process.env.WAKEWORD_MODEL || 'hey_jarvis',
|
||||||
threshold: parseFloat(process.env.WAKEWORD_THRESHOLD || '0.75'),
|
threshold: parseFloat(process.env.WAKEWORD_THRESHOLD || '0.75'),
|
||||||
},
|
},
|
||||||
serial: {
|
serial: {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { type ITriggerService } from './services/trigger.interface.js';
|
|||||||
import { SetupFlow } from './setup/index.js';
|
import { SetupFlow } from './setup/index.js';
|
||||||
import { HardwareService, Emotion } from './hardware/index.js';
|
import { HardwareService, Emotion } from './hardware/index.js';
|
||||||
import { createLogger, setLogForwarder } from './utils/index.js';
|
import { createLogger, setLogForwarder } from './utils/index.js';
|
||||||
|
import { SOUND_TRIGGER } from './utils/sounds.js';
|
||||||
|
|
||||||
const logger = createLogger('main', 'info');
|
const logger = createLogger('main', 'info');
|
||||||
|
|
||||||
@ -165,6 +166,55 @@ async function main(): Promise<void> {
|
|||||||
logForwarder.start(); // Flush logs to backend every 5s
|
logForwarder.start(); // Flush logs to backend every 5s
|
||||||
orchestrator.start();
|
orchestrator.start();
|
||||||
|
|
||||||
|
// ── Remote trigger from desktop app ──
|
||||||
|
|
||||||
|
cloudSocket.on('remote_trigger', async () => {
|
||||||
|
logger.info('🎯 Remote trigger from app — starting conversation');
|
||||||
|
try {
|
||||||
|
await audioService.play(SOUND_TRIGGER);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err }, 'Failed to play trigger beep');
|
||||||
|
}
|
||||||
|
orchestrator.handleTriggerDetected();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Hot-swap trigger mode from desktop app ──
|
||||||
|
|
||||||
|
cloudSocket.on('set_trigger_mode', async (event: { mode: string }) => {
|
||||||
|
const newMode = event.mode as 'wakeword' | 'keyboard';
|
||||||
|
if (!['wakeword', 'keyboard'].includes(newMode)) {
|
||||||
|
logger.warn({ mode: event.mode }, 'Invalid trigger mode from remote');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newMode === resolvedConfig.triggerMode) {
|
||||||
|
logger.info({ mode: newMode }, 'Trigger mode already set');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ from: resolvedConfig.triggerMode, to: newMode }, 'Switching trigger mode');
|
||||||
|
|
||||||
|
// Stop current trigger
|
||||||
|
trigger.stop();
|
||||||
|
|
||||||
|
// Create new trigger
|
||||||
|
if (newMode === 'wakeword') {
|
||||||
|
trigger = new WakeWordService(
|
||||||
|
hardwareConfig.wakeWord,
|
||||||
|
hardwareConfig.audio,
|
||||||
|
audioBackend === 'esp32' ? hardwareService : null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
trigger = new KeyboardTriggerService();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewire orchestrator with new trigger
|
||||||
|
orchestrator.swapTrigger(trigger);
|
||||||
|
resolvedConfig.triggerMode = newMode;
|
||||||
|
|
||||||
|
logger.info({ mode: newMode }, 'Trigger mode switched');
|
||||||
|
});
|
||||||
|
|
||||||
if (resolvedConfig.triggerMode === 'wakeword') {
|
if (resolvedConfig.triggerMode === 'wakeword') {
|
||||||
logger.info('Ti-Pote is ready! Say "Hey Ti-Pote" to start a conversation.');
|
logger.info('Ti-Pote is ready! Say "Hey Ti-Pote" to start a conversation.');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export class OrchestratorService extends EventEmitter {
|
|||||||
/** Timer for Voice Activity Detection (silence timeout) */
|
/** Timer for Voice Activity Detection (silence timeout) */
|
||||||
private silenceTimer: ReturnType<typeof setTimeout> | null = null;
|
private silenceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private readonly silenceTimeoutMs = 2000; // 2s of silence = speech end
|
private readonly silenceTimeoutMs = 2000; // 2s of silence = speech end
|
||||||
private readonly initialGracePeriodMs = 3000; // 3s grace period before silence detection kicks in
|
private readonly initialGracePeriodMs = 5000; // 5s grace period before silence detection kicks in
|
||||||
|
|
||||||
/** Track when the last audio chunk was received */
|
/** Track when the last audio chunk was received */
|
||||||
private lastAudioChunkTime = 0;
|
private lastAudioChunkTime = 0;
|
||||||
@ -39,7 +39,7 @@ export class OrchestratorService extends EventEmitter {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly cloudSocket: CloudSocket,
|
private readonly cloudSocket: CloudSocket,
|
||||||
private readonly audioService: AudioService,
|
private readonly audioService: AudioService,
|
||||||
private readonly trigger: ITriggerService,
|
private trigger: ITriggerService,
|
||||||
private readonly audioConfig: AudioConfig,
|
private readonly audioConfig: AudioConfig,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@ -83,9 +83,10 @@ export class OrchestratorService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle trigger detection (wake word or Enter key).
|
* Handle trigger detection (wake word, Enter key, or remote command).
|
||||||
|
* Public so that external callers (e.g. CloudSocket remote trigger) can invoke it.
|
||||||
*/
|
*/
|
||||||
private async handleTriggerDetected(): Promise<void> {
|
async handleTriggerDetected(): Promise<void> {
|
||||||
if (this.state !== 'idle') {
|
if (this.state !== 'idle') {
|
||||||
this.logger.debug({ state: this.state }, 'Trigger detected but not idle, ignoring');
|
this.logger.debug({ state: this.state }, 'Trigger detected but not idle, ignoring');
|
||||||
return;
|
return;
|
||||||
@ -242,6 +243,14 @@ export class OrchestratorService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.audioBuffer = [];
|
this.audioBuffer = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset speech detection: the wake word phrase ("Hey Jarvis") itself
|
||||||
|
// triggers hasDetectedSpeech=true, and the natural pause after saying it
|
||||||
|
// fires the 2s silence timeout before the user can ask their question.
|
||||||
|
// Restart the clock from NOW so the user gets the full grace period.
|
||||||
|
this.hasDetectedSpeech = false;
|
||||||
|
this.lastAudioChunkTime = Date.now();
|
||||||
|
this.conversationStartTime = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The cloud drives the state machine for thinking → speaking → idle
|
// The cloud drives the state machine for thinking → speaking → idle
|
||||||
@ -268,8 +277,10 @@ export class OrchestratorService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After playback, return to idle and wait for a new wake word
|
// After playback, keep the conversation going — listen for follow-up
|
||||||
this.returnToIdle();
|
// without requiring a new wake word. The conversation ends naturally
|
||||||
|
// when silence exceeds the grace period (no speech detected).
|
||||||
|
this.continueListening();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -314,6 +325,25 @@ export class OrchestratorService extends EventEmitter {
|
|||||||
this.logger.info('💤 Idle — waiting for wake word...');
|
this.logger.info('💤 Idle — waiting for wake word...');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hot-swap the trigger service (e.g., switching between wakeword and keyboard).
|
||||||
|
*/
|
||||||
|
swapTrigger(newTrigger: ITriggerService): void {
|
||||||
|
this.logger.info('Swapping trigger service');
|
||||||
|
|
||||||
|
// Wire up the new trigger's detected event
|
||||||
|
newTrigger.on('detected', () => {
|
||||||
|
this.handleTriggerDetected();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.trigger = newTrigger;
|
||||||
|
|
||||||
|
// Start the new trigger if we're idle
|
||||||
|
if (this.state === 'idle') {
|
||||||
|
newTrigger.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle user interrupt (e.g., button press, or second wake word).
|
* Handle user interrupt (e.g., button press, or second wake word).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -38,16 +38,38 @@ export class WakeWordService extends EventEmitter {
|
|||||||
private _isPaused = false;
|
private _isPaused = false;
|
||||||
private _streamClosed = false;
|
private _streamClosed = false;
|
||||||
private readonly usesHardware: boolean;
|
private readonly usesHardware: boolean;
|
||||||
|
private restartCount = 0;
|
||||||
|
private readonly maxRestarts = 5;
|
||||||
|
/** Set once on first EPIPE — prevents log spam from in-flight chunks. */
|
||||||
|
private _pipeBroken = false;
|
||||||
|
/** Chunk counter for debug — logs every N chunks to confirm audio flows. */
|
||||||
|
private _chunkCount = 0;
|
||||||
|
|
||||||
/** Latched forwarder so we can detach it on stop / error. */
|
/** Latched forwarder so we can detach it on stop / error. */
|
||||||
private readonly forwardMicChunk = (chunk: Buffer): void => {
|
private readonly forwardMicChunk = (chunk: Buffer): void => {
|
||||||
|
if (this._pipeBroken) return;
|
||||||
if (!this.process || !this.process.stdin || this.process.stdin.destroyed) return;
|
if (!this.process || !this.process.stdin || this.process.stdin.destroyed) return;
|
||||||
|
this._chunkCount++;
|
||||||
|
if (this._chunkCount % 500 === 0) {
|
||||||
|
this.logger.info({ chunks: this._chunkCount, paused: this._isPaused }, 'Wake word audio flowing');
|
||||||
|
}
|
||||||
|
try {
|
||||||
this.process.stdin.write(chunk, (err) => {
|
this.process.stdin.write(chunk, (err) => {
|
||||||
if (err && (err as NodeJS.ErrnoException).code === 'EPIPE') {
|
if (err && (err as NodeJS.ErrnoException).code === 'EPIPE') {
|
||||||
this.logger.warn('Wake word process stdin pipe broken — detaching audio');
|
if (!this._pipeBroken) {
|
||||||
|
this._pipeBroken = true;
|
||||||
|
this.logger.warn('Wake word stdin pipe broken — detaching audio');
|
||||||
this.detachHardware();
|
this.detachHardware();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (!this._pipeBroken) {
|
||||||
|
this._pipeBroken = true;
|
||||||
|
this.logger.warn('Wake word stdin write failed — detaching audio');
|
||||||
|
this.detachHardware();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -105,6 +127,20 @@ export class WakeWordService extends EventEmitter {
|
|||||||
|
|
||||||
this._isListening = true;
|
this._isListening = true;
|
||||||
this._isPaused = false;
|
this._isPaused = false;
|
||||||
|
this._pipeBroken = false;
|
||||||
|
|
||||||
|
// Catch EPIPE on stdin so it doesn't bubble as uncaughtException
|
||||||
|
this.process.stdin?.on('error', (err) => {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'EPIPE') {
|
||||||
|
if (!this._pipeBroken) {
|
||||||
|
this._pipeBroken = true;
|
||||||
|
this.logger.debug('Wake word stdin EPIPE (process exited)');
|
||||||
|
this.detachHardware();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.error({ err }, 'Wake word stdin error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.process.stdout?.on('data', (data: Buffer) => {
|
this.process.stdout?.on('data', (data: Buffer) => {
|
||||||
const lines = data.toString().trim().split('\n');
|
const lines = data.toString().trim().split('\n');
|
||||||
@ -129,12 +165,12 @@ export class WakeWordService extends EventEmitter {
|
|||||||
this.emit('ready');
|
this.emit('ready');
|
||||||
} else if (msg === 'PAUSED') {
|
} else if (msg === 'PAUSED') {
|
||||||
this._streamClosed = false;
|
this._streamClosed = false;
|
||||||
this.logger.debug('Wake word paused');
|
this.logger.info('⏸️ Wake word paused');
|
||||||
} else if (msg === 'STREAM_CLOSED') {
|
} else if (msg === 'STREAM_CLOSED') {
|
||||||
this._streamClosed = true;
|
this._streamClosed = true;
|
||||||
this.logger.debug('Wake word audio stream closed');
|
this.logger.debug('Wake word audio stream closed');
|
||||||
} else if (msg === 'RESUMED') {
|
} else if (msg === 'RESUMED') {
|
||||||
this.logger.debug('Wake word resumed');
|
this.logger.info('▶️ Wake word resumed (Python confirmed)');
|
||||||
} else if (msg === 'STREAM_REOPENED') {
|
} else if (msg === 'STREAM_REOPENED') {
|
||||||
this.logger.debug('Wake word audio stream reopened');
|
this.logger.debug('Wake word audio stream reopened');
|
||||||
} else if (msg.startsWith('Loading wake word model')) {
|
} else if (msg.startsWith('Loading wake word model')) {
|
||||||
@ -162,19 +198,42 @@ export class WakeWordService extends EventEmitter {
|
|||||||
this.detachHardware();
|
this.detachHardware();
|
||||||
this.process = null;
|
this.process = null;
|
||||||
if (code !== 0 && code !== null) {
|
if (code !== 0 && code !== null) {
|
||||||
this.logger.warn({ code }, 'Wake word process exited unexpectedly');
|
this.restartCount++;
|
||||||
|
if (this.restartCount > this.maxRestarts) {
|
||||||
|
this.logger.error(
|
||||||
|
{ code, restarts: this.restartCount },
|
||||||
|
'Wake word exceeded max restarts — giving up. Fix the issue and restart the service.',
|
||||||
|
);
|
||||||
|
this.emit('error', new Error(`Wake word crashed ${this.restartCount} times`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.warn({ code, restart: this.restartCount }, 'Wake word process exited unexpectedly');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.logger.info('Restarting wake word detection...');
|
this.logger.info('Restarting wake word detection...');
|
||||||
this.start();
|
this.start();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
// Clean exit — reset counter
|
||||||
|
this.restartCount = 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// In ESP32 mode, start piping mic audio from the UART.
|
// In ESP32 mode, catch errors on the control pipe (fd 3) and start piping mic audio.
|
||||||
if (this.usesHardware && this.hardware) {
|
if (this.usesHardware) {
|
||||||
|
const controlPipe = this.process.stdio[3] as unknown as
|
||||||
|
| (NodeJS.WritableStream & { on?: (event: string, cb: (err: Error) => void) => void })
|
||||||
|
| null;
|
||||||
|
if (controlPipe?.on) {
|
||||||
|
controlPipe.on('error', (err: Error) => {
|
||||||
|
this.logger.debug({ err: (err as NodeJS.ErrnoException).code }, 'Control pipe error (process exited)');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hardware) {
|
||||||
this.hardware.on('audio_up', this.forwardMicChunk);
|
this.hardware.on('audio_up', this.forwardMicChunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pause wake word detection.
|
* Pause wake word detection.
|
||||||
|
|||||||
@ -29,8 +29,17 @@ export class WifiService {
|
|||||||
*/
|
*/
|
||||||
async isConnected(): Promise<boolean> {
|
async isConnected(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync('nmcli -t -f TYPE,STATE device | grep wifi');
|
// Use iwgetid first (no privileges needed), fallback to nmcli
|
||||||
return stdout.includes('connected') && !stdout.includes('disconnected');
|
const { stdout } = await execAsync('iwgetid -r');
|
||||||
|
if (stdout.trim()) return true;
|
||||||
|
} catch {
|
||||||
|
// iwgetid failed, try nmcli
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('nmcli -t -f TYPE,STATE device');
|
||||||
|
// Check that the main wifi device (not wifi-p2p) is connected
|
||||||
|
return stdout.split('\n').some((line) => line === 'wifi:connected');
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,8 @@ export interface CloudSocketEvents {
|
|||||||
response_text: (event: ResponseTextEvent) => void;
|
response_text: (event: ResponseTextEvent) => void;
|
||||||
audio_chunk: (event: AudioChunkEvent) => void;
|
audio_chunk: (event: AudioChunkEvent) => void;
|
||||||
notification: (payload: Record<string, unknown>) => void;
|
notification: (payload: Record<string, unknown>) => void;
|
||||||
|
remote_trigger: () => void;
|
||||||
|
set_trigger_mode: (event: { mode: string }) => void;
|
||||||
error: (error: Error) => void;
|
error: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,6 +125,18 @@ export class CloudSocket extends EventEmitter {
|
|||||||
this.logger.info({ payload }, 'Notification received');
|
this.logger.info({ payload }, 'Notification received');
|
||||||
this.emit('notification', payload);
|
this.emit('notification', payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Remote control events from desktop app (via AppGateway → RobotGateway) ──
|
||||||
|
|
||||||
|
this.socket.on('remote_trigger', () => {
|
||||||
|
this.logger.info('Remote trigger received from app');
|
||||||
|
this.emit('remote_trigger');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('set_trigger_mode', (event: { mode: string }) => {
|
||||||
|
this.logger.info({ mode: event.mode }, 'Trigger mode change from app');
|
||||||
|
this.emit('set_trigger_mode', event);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
80
apps/robot-client/src/utils/sounds.ts
Normal file
80
apps/robot-client/src/utils/sounds.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Pre-generated notification sounds for the robot.
|
||||||
|
*
|
||||||
|
* All sounds are raw S16LE mono PCM at 16 kHz, ready to pass to AudioService.play().
|
||||||
|
* Generated once at import time — zero runtime cost per playback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SAMPLE_RATE = 16_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a sine wave tone with attack/release envelope to prevent speaker clicks.
|
||||||
|
*/
|
||||||
|
function generateTone(freqHz: number, durationMs: number, amplitude = 0.5): Buffer {
|
||||||
|
const sampleCount = Math.floor((SAMPLE_RATE * durationMs) / 1000);
|
||||||
|
const buf = Buffer.alloc(sampleCount * 2); // 16-bit = 2 bytes per sample
|
||||||
|
const amp = Math.max(0, Math.min(1, amplitude)) * 32767;
|
||||||
|
const twoPiF = (2 * Math.PI * freqHz) / SAMPLE_RATE;
|
||||||
|
|
||||||
|
// 5ms linear attack/release envelope
|
||||||
|
const rampSamples = Math.floor((SAMPLE_RATE * 5) / 1000);
|
||||||
|
|
||||||
|
for (let i = 0; i < sampleCount; i++) {
|
||||||
|
let env = 1;
|
||||||
|
if (i < rampSamples) env = i / rampSamples;
|
||||||
|
else if (i > sampleCount - rampSamples) env = (sampleCount - i) / rampSamples;
|
||||||
|
|
||||||
|
const s = Math.round(Math.sin(i * twoPiF) * amp * env);
|
||||||
|
buf.writeInt16LE(Math.max(-32768, Math.min(32767, s)), i * 2);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concatenate multiple PCM buffers with silence gaps between them.
|
||||||
|
*/
|
||||||
|
function concat(parts: Buffer[], gapMs = 0): Buffer {
|
||||||
|
if (gapMs <= 0) return Buffer.concat(parts);
|
||||||
|
|
||||||
|
const gapBytes = Math.floor((SAMPLE_RATE * gapMs) / 1000) * 2;
|
||||||
|
const silence = Buffer.alloc(gapBytes);
|
||||||
|
const result: Buffer[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
result.push(parts[i]);
|
||||||
|
if (i < parts.length - 1) result.push(silence);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pre-generated sounds ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short rising two-tone chirp: "bi-bip!" (80ms + 80ms)
|
||||||
|
* Played when a remote trigger starts a conversation.
|
||||||
|
*/
|
||||||
|
export const SOUND_TRIGGER = concat(
|
||||||
|
[
|
||||||
|
generateTone(660, 80, 0.4), // E5
|
||||||
|
generateTone(880, 80, 0.4), // A5
|
||||||
|
],
|
||||||
|
30, // 30ms gap
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single soft beep for generic notifications.
|
||||||
|
*/
|
||||||
|
export const SOUND_NOTIFY = generateTone(440, 120, 0.3);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three descending tones for errors.
|
||||||
|
*/
|
||||||
|
export const SOUND_ERROR = concat(
|
||||||
|
[
|
||||||
|
generateTone(880, 100, 0.35),
|
||||||
|
generateTone(660, 100, 0.35),
|
||||||
|
generateTone(440, 150, 0.35),
|
||||||
|
],
|
||||||
|
40,
|
||||||
|
);
|
||||||
100
pnpm-lock.yaml
generated
100
pnpm-lock.yaml
generated
@ -10,6 +10,9 @@ importers:
|
|||||||
|
|
||||||
apps/backend:
|
apps/backend:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@ai-sdk/mistral':
|
||||||
|
specifier: ^3.0.30
|
||||||
|
version: 3.0.30(zod@4.3.6)
|
||||||
'@anthropic-ai/sdk':
|
'@anthropic-ai/sdk':
|
||||||
specifier: ^0.80.0
|
specifier: ^0.80.0
|
||||||
version: 0.80.0(zod@4.3.6)
|
version: 0.80.0(zod@4.3.6)
|
||||||
@ -19,6 +22,9 @@ importers:
|
|||||||
'@mastra/core':
|
'@mastra/core':
|
||||||
specifier: ^1.17.0
|
specifier: ^1.17.0
|
||||||
version: 1.17.0(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.3.6))(@types/json-schema@7.0.15)(openapi-types@12.1.3)(zod@4.3.6)
|
version: 1.17.0(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.3.6))(@types/json-schema@7.0.15)(openapi-types@12.1.3)(zod@4.3.6)
|
||||||
|
'@mistralai/mistralai':
|
||||||
|
specifier: ^2.2.0
|
||||||
|
version: 2.2.0
|
||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^11.1.17
|
specifier: ^11.1.17
|
||||||
version: 11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@ -28,6 +34,9 @@ importers:
|
|||||||
'@nestjs/core':
|
'@nestjs/core':
|
||||||
specifier: ^11.1.17
|
specifier: ^11.1.17
|
||||||
version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/event-emitter':
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)
|
||||||
'@nestjs/jwt':
|
'@nestjs/jwt':
|
||||||
specifier: ^11.0.2
|
specifier: ^11.0.2
|
||||||
version: 11.0.2(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
version: 11.0.2(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||||
@ -167,6 +176,9 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.26.0
|
specifier: ^6.26.0
|
||||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
socket.io-client:
|
||||||
|
specifier: ^4.8.3
|
||||||
|
version: 4.8.3
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^4.5.5
|
specifier: ^4.5.5
|
||||||
version: 4.5.7(@types/react@18.3.28)(react@18.3.1)
|
version: 4.5.7(@types/react@18.3.28)(react@18.3.1)
|
||||||
@ -210,6 +222,9 @@ importers:
|
|||||||
pino-pretty:
|
pino-pretty:
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.1.3
|
version: 13.1.3
|
||||||
|
sd-notify:
|
||||||
|
specifier: ^2.8.0
|
||||||
|
version: 2.8.0
|
||||||
serialport:
|
serialport:
|
||||||
specifier: ^12.0.0
|
specifier: ^12.0.0
|
||||||
version: 12.0.0
|
version: 12.0.0
|
||||||
@ -239,6 +254,8 @@ importers:
|
|||||||
specifier: ^3.2.1
|
specifier: ^3.2.1
|
||||||
version: 3.2.4(@types/node@22.19.15)(lightningcss@1.32.0)(terser@5.46.1)
|
version: 3.2.4(@types/node@22.19.15)(lightningcss@1.32.0)(terser@5.46.1)
|
||||||
|
|
||||||
|
apps/robot-client-pi: {}
|
||||||
|
|
||||||
apps/simulator:
|
apps/simulator:
|
||||||
dependencies:
|
dependencies:
|
||||||
react:
|
react:
|
||||||
@ -273,6 +290,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-VTDuRS5V0ATbJ/LkaQlisMnTAeYKXAK6scMguVBstf+KIBQ7HIuKhiXLv+G/hvejkV+THoXzoNifInAkU81P1g==}
|
resolution: {integrity: sha512-VTDuRS5V0ATbJ/LkaQlisMnTAeYKXAK6scMguVBstf+KIBQ7HIuKhiXLv+G/hvejkV+THoXzoNifInAkU81P1g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@ai-sdk/mistral@3.0.30':
|
||||||
|
resolution: {integrity: sha512-+j4IXRSk9E661cFSafmIr+XHOzwjFagawwzMOlSqwL6U4Sq4PCFLDF+oHbX5NUqNjUL7FD1zi/9lBIfa41pUvw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.25.76 || ^4.1.8
|
||||||
|
|
||||||
'@ai-sdk/provider-utils@2.2.8':
|
'@ai-sdk/provider-utils@2.2.8':
|
||||||
resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==}
|
resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -291,6 +314,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.25.76 || ^4.1.8
|
zod: ^3.25.76 || ^4.1.8
|
||||||
|
|
||||||
|
'@ai-sdk/provider-utils@4.0.23':
|
||||||
|
resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.25.76 || ^4.1.8
|
||||||
|
|
||||||
'@ai-sdk/provider@1.1.3':
|
'@ai-sdk/provider@1.1.3':
|
||||||
resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==}
|
resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -307,6 +336,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w==}
|
resolution: {integrity: sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@ai-sdk/provider@3.0.8':
|
||||||
|
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@ai-sdk/ui-utils@1.2.11':
|
'@ai-sdk/ui-utils@1.2.11':
|
||||||
resolution: {integrity: sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==}
|
resolution: {integrity: sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -1194,6 +1227,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.25.0 || ^4.0.0
|
zod: ^3.25.0 || ^4.0.0
|
||||||
|
|
||||||
|
'@mistralai/mistralai@2.2.0':
|
||||||
|
resolution: {integrity: sha512-JQUGIXjFWnw/J9LpTSf/ZXwVW3Sh8FBAcfTo5QvAHqkl4CfSiIwnjRJhMoAFcP6ncCe84YPU1ncDGX+p3OXnfg==}
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.28.0':
|
'@modelcontextprotocol/sdk@1.28.0':
|
||||||
resolution: {integrity: sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==}
|
resolution: {integrity: sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -1260,6 +1296,12 @@ packages:
|
|||||||
'@nestjs/websockets':
|
'@nestjs/websockets':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nestjs/event-emitter@3.0.1':
|
||||||
|
resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||||
|
'@nestjs/core': ^10.0.0 || ^11.0.0
|
||||||
|
|
||||||
'@nestjs/jwt@11.0.2':
|
'@nestjs/jwt@11.0.2':
|
||||||
resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==}
|
resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2479,6 +2521,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
bindings@1.5.0:
|
||||||
|
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
||||||
|
|
||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||||
|
|
||||||
@ -3055,6 +3100,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
eventemitter2@6.4.9:
|
||||||
|
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
|
||||||
|
|
||||||
events@3.3.0:
|
events@3.3.0:
|
||||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||||
engines: {node: '>=0.8.x'}
|
engines: {node: '>=0.8.x'}
|
||||||
@ -3157,6 +3205,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==}
|
resolution: {integrity: sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
file-uri-to-path@1.0.0:
|
||||||
|
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -4650,6 +4701,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
|
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
|
||||||
engines: {node: '>= 10.13.0'}
|
engines: {node: '>= 10.13.0'}
|
||||||
|
|
||||||
|
sd-notify@2.8.0:
|
||||||
|
resolution: {integrity: sha512-e+D1v0Y6UzmqXcPlaTkHk1QMdqk36mF/jIYv5gwry/N2Tb8/UNnpfG6ktGLpeBOR6TCC5hPKgqA+0hTl9sm2tA==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
os: [linux, darwin, win32]
|
||||||
|
|
||||||
section-matter@1.0.0:
|
section-matter@1.0.0:
|
||||||
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
|
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@ -5542,6 +5598,12 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@ai-sdk/mistral@3.0.30(zod@4.3.6)':
|
||||||
|
dependencies:
|
||||||
|
'@ai-sdk/provider': 3.0.8
|
||||||
|
'@ai-sdk/provider-utils': 4.0.23(zod@4.3.6)
|
||||||
|
zod: 4.3.6
|
||||||
|
|
||||||
'@ai-sdk/provider-utils@2.2.8(zod@4.3.6)':
|
'@ai-sdk/provider-utils@2.2.8(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/provider': 1.1.3
|
'@ai-sdk/provider': 1.1.3
|
||||||
@ -5563,6 +5625,13 @@ snapshots:
|
|||||||
eventsource-parser: 3.0.6
|
eventsource-parser: 3.0.6
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
|
|
||||||
|
'@ai-sdk/provider-utils@4.0.23(zod@4.3.6)':
|
||||||
|
dependencies:
|
||||||
|
'@ai-sdk/provider': 3.0.8
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
eventsource-parser: 3.0.6
|
||||||
|
zod: 4.3.6
|
||||||
|
|
||||||
'@ai-sdk/provider@1.1.3':
|
'@ai-sdk/provider@1.1.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
json-schema: 0.4.0
|
json-schema: 0.4.0
|
||||||
@ -5579,6 +5648,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
json-schema: 0.4.0
|
json-schema: 0.4.0
|
||||||
|
|
||||||
|
'@ai-sdk/provider@3.0.8':
|
||||||
|
dependencies:
|
||||||
|
json-schema: 0.4.0
|
||||||
|
|
||||||
'@ai-sdk/ui-utils@1.2.11(zod@4.3.6)':
|
'@ai-sdk/ui-utils@1.2.11(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/provider': 1.1.3
|
'@ai-sdk/provider': 1.1.3
|
||||||
@ -6498,6 +6571,15 @@ snapshots:
|
|||||||
zod-from-json-schema-v3: zod-from-json-schema@0.0.5
|
zod-from-json-schema-v3: zod-from-json-schema@0.0.5
|
||||||
zod-to-json-schema: 3.25.2(zod@4.3.6)
|
zod-to-json-schema: 3.25.2(zod@4.3.6)
|
||||||
|
|
||||||
|
'@mistralai/mistralai@2.2.0':
|
||||||
|
dependencies:
|
||||||
|
ws: 8.20.0
|
||||||
|
zod: 4.3.6
|
||||||
|
zod-to-json-schema: 3.25.2(zod@4.3.6)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)':
|
'@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hono/node-server': 1.19.11(hono@4.12.9)
|
'@hono/node-server': 1.19.11(hono@4.12.9)
|
||||||
@ -6598,6 +6680,12 @@ snapshots:
|
|||||||
'@nestjs/platform-express': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)
|
'@nestjs/platform-express': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)
|
||||||
'@nestjs/websockets': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/websockets': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
|
||||||
|
'@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
eventemitter2: 6.4.9
|
||||||
|
|
||||||
'@nestjs/jwt@11.0.2(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
|
'@nestjs/jwt@11.0.2(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@ -7700,6 +7788,10 @@ snapshots:
|
|||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
|
bindings@1.5.0:
|
||||||
|
dependencies:
|
||||||
|
file-uri-to-path: 1.0.0
|
||||||
|
|
||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
buffer: 5.7.1
|
buffer: 5.7.1
|
||||||
@ -8307,6 +8399,8 @@ snapshots:
|
|||||||
|
|
||||||
event-target-shim@5.0.1: {}
|
event-target-shim@5.0.1: {}
|
||||||
|
|
||||||
|
eventemitter2@6.4.9: {}
|
||||||
|
|
||||||
events@3.3.0: {}
|
events@3.3.0: {}
|
||||||
|
|
||||||
eventsource-parser@3.0.6: {}
|
eventsource-parser@3.0.6: {}
|
||||||
@ -8484,6 +8578,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
file-uri-to-path@1.0.0: {}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
@ -10077,6 +10173,10 @@ snapshots:
|
|||||||
ajv-formats: 2.1.1(ajv@8.18.0)
|
ajv-formats: 2.1.1(ajv@8.18.0)
|
||||||
ajv-keywords: 5.1.0(ajv@8.18.0)
|
ajv-keywords: 5.1.0(ajv@8.18.0)
|
||||||
|
|
||||||
|
sd-notify@2.8.0:
|
||||||
|
dependencies:
|
||||||
|
bindings: 1.5.0
|
||||||
|
|
||||||
section-matter@1.0.0:
|
section-matter@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
extend-shallow: 2.0.1
|
extend-shallow: 2.0.1
|
||||||
|
|||||||
36
tools/pi-gen-tipote/Dockerfile
Normal file
36
tools/pi-gen-tipote/Dockerfile
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install pi-gen dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git coreutils quilt parted \
|
||||||
|
debootstrap zerofree zip dosfstools \
|
||||||
|
libarchive-tools libcap2-bin grep rsync xz-utils \
|
||||||
|
file binfmt-support qemu-user-static \
|
||||||
|
pigz arch-test \
|
||||||
|
curl bc xxd kmod \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Clone pi-gen (bookworm branch for Raspberry Pi OS stable)
|
||||||
|
RUN git clone --depth 1 --branch bookworm https://github.com/RPi-Distro/pi-gen.git /build/pi-gen
|
||||||
|
|
||||||
|
# Copy config and stage
|
||||||
|
COPY config /build/pi-gen/config
|
||||||
|
COPY stage3/ /build/pi-gen/stage3/
|
||||||
|
|
||||||
|
# Skip stage2 image export (we export from stage3)
|
||||||
|
RUN touch /build/pi-gen/stage2/SKIP_IMAGES
|
||||||
|
|
||||||
|
# Patch pi-gen for Docker compatibility:
|
||||||
|
# 1. Remove setarch linux32 calls (fails on some Docker setups)
|
||||||
|
# 2. Remove qemu-user-binfmt from dependency check (conflicts with qemu-user-static)
|
||||||
|
RUN find /build/pi-gen -name "*.sh" -exec sed -i 's/setarch linux32 //g' {} + 2>/dev/null || true \
|
||||||
|
&& sed -i '/qemu-user-binfmt/d' /build/pi-gen/depends
|
||||||
|
|
||||||
|
COPY entrypoint.sh /build/entrypoint.sh
|
||||||
|
RUN chmod +x /build/entrypoint.sh
|
||||||
|
|
||||||
|
CMD ["/build/entrypoint.sh"]
|
||||||
@ -3,14 +3,15 @@ set -euo pipefail
|
|||||||
|
|
||||||
# ──────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────
|
||||||
# Ti-Pote SD Image Builder
|
# Ti-Pote SD Image Builder
|
||||||
# Builds a flashable .img using pi-gen (Docker mode)
|
# Builds a flashable .img using pi-gen in a Docker container.
|
||||||
|
# Works on macOS (Docker Desktop) and Linux.
|
||||||
# ──────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
ROBOT_CLIENT_DIR="$REPO_ROOT/apps/robot-client"
|
ROBOT_CLIENT_DIR="$REPO_ROOT/apps/robot-client"
|
||||||
STAGE_FILES="$SCRIPT_DIR/stage-tipote/01-install-tipote/files"
|
STAGE_FILES="$SCRIPT_DIR/stage-tipote/01-install-tipote/files"
|
||||||
PI_GEN_DIR="$SCRIPT_DIR/pi-gen"
|
OUTPUT_DIR="$SCRIPT_DIR/output"
|
||||||
|
|
||||||
echo "╔══════════════════════════════════════╗"
|
echo "╔══════════════════════════════════════╗"
|
||||||
echo "║ Ti-Pote SD Image Builder ║"
|
echo "║ Ti-Pote SD Image Builder ║"
|
||||||
@ -19,73 +20,59 @@ echo "╚═══════════════════════
|
|||||||
# ── Step 1: Build robot-client ──
|
# ── Step 1: Build robot-client ──
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "▸ Building robot-client..."
|
echo "▸ [1/3] Building robot-client..."
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
pnpm --filter @ti-pote/robot-client build
|
pnpm --filter @ti-pote/robot-client build
|
||||||
|
|
||||||
# ── Step 2: Prepare files for pi-gen stage ──
|
# ── Step 2: Prepare stage files ──
|
||||||
|
|
||||||
echo "▸ Preparing stage files..."
|
echo "▸ [2/3] Preparing stage files..."
|
||||||
rm -rf "$STAGE_FILES"
|
rm -rf "$STAGE_FILES"
|
||||||
mkdir -p "$STAGE_FILES"
|
mkdir -p "$STAGE_FILES"
|
||||||
|
|
||||||
# Copy built dist
|
|
||||||
cp -r "$ROBOT_CLIENT_DIR/dist" "$STAGE_FILES/dist"
|
cp -r "$ROBOT_CLIENT_DIR/dist" "$STAGE_FILES/dist"
|
||||||
|
|
||||||
# Copy package.json (for npm install --omit=dev in chroot)
|
|
||||||
cp "$ROBOT_CLIENT_DIR/package.json" "$STAGE_FILES/package.json"
|
cp "$ROBOT_CLIENT_DIR/package.json" "$STAGE_FILES/package.json"
|
||||||
|
|
||||||
# Copy deploy configs
|
|
||||||
cp "$ROBOT_CLIENT_DIR/deploy/tipote.service" "$STAGE_FILES/tipote.service"
|
cp "$ROBOT_CLIENT_DIR/deploy/tipote.service" "$STAGE_FILES/tipote.service"
|
||||||
cp "$ROBOT_CLIENT_DIR/deploy/journald-tipote.conf" "$STAGE_FILES/journald-tipote.conf"
|
cp "$ROBOT_CLIENT_DIR/deploy/journald-tipote.conf" "$STAGE_FILES/journald-tipote.conf"
|
||||||
|
|
||||||
# ── Step 3: Clone or update pi-gen ──
|
# ── Step 3: Build image in Docker ──
|
||||||
|
|
||||||
if [ ! -d "$PI_GEN_DIR" ]; then
|
echo "▸ [3/3] Building SD image in Docker (this takes 30-60 min)..."
|
||||||
echo "▸ Cloning pi-gen..."
|
cd "$SCRIPT_DIR"
|
||||||
git clone --depth 1 https://github.com/RPi-Distro/pi-gen.git "$PI_GEN_DIR"
|
|
||||||
else
|
|
||||||
echo "▸ pi-gen already cloned"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Step 4: Configure pi-gen ──
|
# Register QEMU ARM interpreters in Docker Desktop's VM
|
||||||
|
echo " → Registering ARM emulation..."
|
||||||
|
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 2>/dev/null || true
|
||||||
|
|
||||||
echo "▸ Configuring pi-gen..."
|
# Build the builder image (force amd64 — pi-gen cross-compiles to ARM from x86)
|
||||||
cp "$SCRIPT_DIR/config" "$PI_GEN_DIR/config"
|
docker build --platform linux/amd64 -t tipote-builder .
|
||||||
|
|
||||||
# Link our custom stage
|
# Run the build — mount output dir to retrieve the .img
|
||||||
ln -sfn "$SCRIPT_DIR/stage-tipote" "$PI_GEN_DIR/stage-tipote"
|
|
||||||
|
|
||||||
# Skip stages 3-5 (desktop, apps — we only want Lite + our stage)
|
|
||||||
touch "$PI_GEN_DIR/stage3/SKIP" "$PI_GEN_DIR/stage4/SKIP" "$PI_GEN_DIR/stage5/SKIP"
|
|
||||||
touch "$PI_GEN_DIR/stage3/SKIP_IMAGES" "$PI_GEN_DIR/stage4/SKIP_IMAGES" "$PI_GEN_DIR/stage5/SKIP_IMAGES"
|
|
||||||
|
|
||||||
# Don't export images from stage2 (we export from stage-tipote)
|
|
||||||
touch "$PI_GEN_DIR/stage2/SKIP_IMAGES"
|
|
||||||
|
|
||||||
# ── Step 5: Build image ──
|
|
||||||
|
|
||||||
echo "▸ Building image (this will take a while)..."
|
|
||||||
cd "$PI_GEN_DIR"
|
|
||||||
./build-docker.sh
|
|
||||||
|
|
||||||
# ── Step 6: Copy result ──
|
|
||||||
|
|
||||||
OUTPUT_DIR="$SCRIPT_DIR/output"
|
|
||||||
mkdir -p "$OUTPUT_DIR"
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
docker run --rm --privileged \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
-v /proc/sys/fs/binfmt_misc:/proc/sys/fs/binfmt_misc:rw \
|
||||||
|
-v "$OUTPUT_DIR:/output" \
|
||||||
|
tipote-builder
|
||||||
|
|
||||||
IMG_FILE=$(find "$PI_GEN_DIR/deploy" -name "*.img.xz" -type f | head -1)
|
# ── Done ──
|
||||||
|
|
||||||
|
IMG_FILE=$(find "$OUTPUT_DIR" -name "*.img*" -type f | head -1)
|
||||||
|
|
||||||
if [ -n "$IMG_FILE" ]; then
|
if [ -n "$IMG_FILE" ]; then
|
||||||
cp "$IMG_FILE" "$OUTPUT_DIR/"
|
|
||||||
BASENAME=$(basename "$IMG_FILE")
|
BASENAME=$(basename "$IMG_FILE")
|
||||||
echo ""
|
echo ""
|
||||||
echo "════════════════════════════════════════"
|
echo "════════════════════════════════════════"
|
||||||
echo " Image ready: output/$BASENAME"
|
echo " Image ready: output/$BASENAME"
|
||||||
echo " Flash with: xzcat output/$BASENAME | sudo dd of=/dev/sdX bs=4M status=progress"
|
SIZE=$(ls -lh "$IMG_FILE" | awk '{print $5}')
|
||||||
echo " Or use Balena Etcher"
|
echo " Size: $SIZE"
|
||||||
|
echo ""
|
||||||
|
echo " Flash with:"
|
||||||
|
echo " xzcat output/$BASENAME | sudo dd of=/dev/sdX bs=4M status=progress"
|
||||||
|
echo " Or drag into Balena Etcher / Raspberry Pi Imager"
|
||||||
echo "════════════════════════════════════════"
|
echo "════════════════════════════════════════"
|
||||||
else
|
else
|
||||||
echo "⚠ No image found in pi-gen/deploy/ — check build logs"
|
echo ""
|
||||||
|
echo "⚠ No image found — check Docker logs above"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
IMG_NAME=tipote
|
IMG_NAME=tipote
|
||||||
RELEASE=trixie
|
RELEASE=bookworm
|
||||||
TARGET_HOSTNAME=tipote
|
TARGET_HOSTNAME=tipote
|
||||||
FIRST_USER_NAME=tipote
|
FIRST_USER_NAME=tipote
|
||||||
FIRST_USER_PASS=tipote
|
FIRST_USER_PASS=tipote
|
||||||
@ -9,7 +9,5 @@ KEYBOARD_LAYOUT="French"
|
|||||||
TIMEZONE_DEFAULT=Europe/Paris
|
TIMEZONE_DEFAULT=Europe/Paris
|
||||||
ENABLE_SSH=1
|
ENABLE_SSH=1
|
||||||
|
|
||||||
# Skip stages we don't need (desktop, X11, etc.)
|
# Only run base stages + our custom stage (skip desktop/apps)
|
||||||
SKIP_STAGE3=1
|
STAGE_LIST="stage0 stage1 stage2 stage3"
|
||||||
SKIP_STAGE4=1
|
|
||||||
SKIP_STAGE5=1
|
|
||||||
|
|||||||
153
tools/pi-gen-tipote/deploy.sh
Executable file
153
tools/pi-gen-tipote/deploy.sh
Executable file
@ -0,0 +1,153 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────
|
||||||
|
# Ti-Pote Remote Deployment Script
|
||||||
|
#
|
||||||
|
# Deploys Ti-Pote to a fresh Raspberry Pi OS Lite.
|
||||||
|
# Run from your dev machine (macOS/Linux).
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# 1. Flash Raspberry Pi OS Lite (64-bit, Debian trixie)
|
||||||
|
# using Raspberry Pi Imager
|
||||||
|
# 2. Enable SSH + set user "tipote" in Imager settings
|
||||||
|
# 3. Connect Pi to your local network (Ethernet or WiFi)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./deploy.sh <pi-ip-or-hostname>
|
||||||
|
# ./deploy.sh 192.168.1.124
|
||||||
|
# ./deploy.sh ti-pote.local
|
||||||
|
# ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if [ $# -lt 1 ]; then
|
||||||
|
echo "Usage: $0 <pi-host> [pi-user]"
|
||||||
|
echo " e.g. $0 192.168.1.124"
|
||||||
|
echo " e.g. $0 ti-pote.local tipote"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PI_HOST="$1"
|
||||||
|
PI_USER="${2:-tipote}"
|
||||||
|
SSH="ssh ${PI_USER}@${PI_HOST}"
|
||||||
|
SCP="scp -r"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
ROBOT_CLIENT_DIR="$REPO_ROOT/apps/robot-client"
|
||||||
|
|
||||||
|
echo "╔══════════════════════════════════════╗"
|
||||||
|
echo "║ Ti-Pote Remote Deployment ║"
|
||||||
|
echo "╠══════════════════════════════════════╣"
|
||||||
|
echo "║ Target: ${PI_USER}@${PI_HOST}"
|
||||||
|
echo "╚══════════════════════════════════════╝"
|
||||||
|
|
||||||
|
# ── Step 1: Build robot-client ──
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "▸ [1/7] Building robot-client..."
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
pnpm --filter @ti-pote/robot-client build
|
||||||
|
|
||||||
|
# ── Step 2: Test SSH connectivity ──
|
||||||
|
|
||||||
|
echo "▸ [2/7] Testing SSH connection..."
|
||||||
|
$SSH "echo 'SSH OK — $(uname -m) — $(cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2)'" || {
|
||||||
|
echo "✗ Cannot reach ${PI_USER}@${PI_HOST} via SSH"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Step 3: Install system dependencies ──
|
||||||
|
|
||||||
|
echo "▸ [3/7] Installing system dependencies..."
|
||||||
|
$SSH "sudo apt-get update -qq && sudo apt-get install -y -qq \
|
||||||
|
python3 python3-venv python3-pip \
|
||||||
|
portaudio19-dev alsa-utils \
|
||||||
|
network-manager curl \
|
||||||
|
libsystemd-dev build-essential"
|
||||||
|
|
||||||
|
# Install Node.js 22 if not present
|
||||||
|
$SSH "node --version 2>/dev/null | grep -q 'v22' || {
|
||||||
|
echo 'Installing Node.js 22...'
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -
|
||||||
|
sudo apt-get install -y -qq nodejs
|
||||||
|
}"
|
||||||
|
|
||||||
|
# ── Step 4: Create tipote directory structure ──
|
||||||
|
|
||||||
|
echo "▸ [4/7] Setting up /opt/tipote..."
|
||||||
|
$SSH "sudo mkdir -p /opt/tipote && sudo chown ${PI_USER}:${PI_USER} /opt/tipote"
|
||||||
|
|
||||||
|
# ── Step 5: Deploy files ──
|
||||||
|
|
||||||
|
echo "▸ [5/7] Uploading files..."
|
||||||
|
|
||||||
|
# Sync dist/, scripts/, and package.json
|
||||||
|
rsync -az --delete \
|
||||||
|
"$ROBOT_CLIENT_DIR/dist/" \
|
||||||
|
"${PI_USER}@${PI_HOST}:/opt/tipote/dist/"
|
||||||
|
|
||||||
|
rsync -az --delete \
|
||||||
|
"$ROBOT_CLIENT_DIR/scripts/wake_word.py" \
|
||||||
|
"${PI_USER}@${PI_HOST}:/opt/tipote/scripts/"
|
||||||
|
|
||||||
|
$SCP "$ROBOT_CLIENT_DIR/package.json" "${PI_USER}@${PI_HOST}:/opt/tipote/package.json"
|
||||||
|
|
||||||
|
# Install production deps on Pi
|
||||||
|
echo "▸ [5/7] Installing production dependencies on Pi..."
|
||||||
|
$SSH "cd /opt/tipote && npm install --omit=dev 2>/dev/null || npm install --production"
|
||||||
|
|
||||||
|
# ── Step 6: Setup Python venv + openwakeword ──
|
||||||
|
|
||||||
|
echo "▸ [6/7] Setting up Python venv..."
|
||||||
|
$SSH "test -d /opt/tipote/.venv || {
|
||||||
|
python3 -m venv /opt/tipote/.venv
|
||||||
|
/opt/tipote/.venv/bin/pip install --upgrade pip
|
||||||
|
/opt/tipote/.venv/bin/pip install openwakeword
|
||||||
|
}"
|
||||||
|
|
||||||
|
# ── Step 7: Install systemd + configs ──
|
||||||
|
|
||||||
|
echo "▸ [7/7] Installing systemd service..."
|
||||||
|
|
||||||
|
# .env (only if not exists — don't overwrite user config)
|
||||||
|
$SSH "test -f /opt/tipote/.env || cat > /opt/tipote/.env << 'ENVEOF'
|
||||||
|
ROBOT_MODE=physical
|
||||||
|
CLOUD_URL=https://api.tipote.dev
|
||||||
|
AUDIO_BACKEND=esp32
|
||||||
|
TRIGGER_MODE=wakeword
|
||||||
|
HARDWARE_SERIAL_ENABLED=true
|
||||||
|
HARDWARE_SERIAL_PORT=/dev/serial0
|
||||||
|
HARDWARE_SERIAL_BAUD_RATE=921600
|
||||||
|
WAKEWORD_PYTHON_PATH=/opt/tipote/.venv/bin/python3
|
||||||
|
WAKEWORD_MODEL=hey_jarvis
|
||||||
|
NODE_ENV=production
|
||||||
|
LOG_LEVEL=info
|
||||||
|
ENVEOF
|
||||||
|
chmod 600 /opt/tipote/.env"
|
||||||
|
|
||||||
|
# systemd service
|
||||||
|
$SCP "$ROBOT_CLIENT_DIR/deploy/tipote.service" "${PI_USER}@${PI_HOST}:/tmp/tipote.service"
|
||||||
|
$SSH "sudo mv /tmp/tipote.service /etc/systemd/system/tipote.service && sudo systemctl daemon-reload && sudo systemctl enable tipote"
|
||||||
|
|
||||||
|
# journald config
|
||||||
|
$SCP "$ROBOT_CLIENT_DIR/deploy/journald-tipote.conf" "${PI_USER}@${PI_HOST}:/tmp/journald-tipote.conf"
|
||||||
|
$SSH "sudo mkdir -p /etc/systemd/journald.conf.d && sudo mv /tmp/journald-tipote.conf /etc/systemd/journald.conf.d/tipote.conf"
|
||||||
|
|
||||||
|
# CLI symlink
|
||||||
|
$SSH "sudo ln -sf /opt/tipote/dist/cli.js /usr/local/bin/tipote && sudo chmod +x /opt/tipote/dist/cli.js"
|
||||||
|
|
||||||
|
# Permissions
|
||||||
|
$SSH "mkdir -p /opt/tipote/.tipote && chmod 700 /opt/tipote/.tipote"
|
||||||
|
|
||||||
|
# Add user to dialout + audio groups (for serial + audio)
|
||||||
|
$SSH "sudo usermod -aG dialout,audio ${PI_USER} 2>/dev/null || true"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "════════════════════════════════════════"
|
||||||
|
echo " Deployment complete!"
|
||||||
|
echo ""
|
||||||
|
echo " Start: ssh ${PI_USER}@${PI_HOST} 'sudo systemctl start tipote'"
|
||||||
|
echo " Status: ssh ${PI_USER}@${PI_HOST} 'sudo systemctl status tipote'"
|
||||||
|
echo " Logs: ssh ${PI_USER}@${PI_HOST} 'journalctl -u tipote -f'"
|
||||||
|
echo " CLI: ssh ${PI_USER}@${PI_HOST} 'tipote doctor'"
|
||||||
|
echo "════════════════════════════════════════"
|
||||||
33
tools/pi-gen-tipote/entrypoint.sh
Executable file
33
tools/pi-gen-tipote/entrypoint.sh
Executable file
@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Load binfmt_misc for ARM emulation
|
||||||
|
modprobe binfmt_misc 2>/dev/null || true
|
||||||
|
mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc 2>/dev/null || true
|
||||||
|
|
||||||
|
cd /build/pi-gen
|
||||||
|
|
||||||
|
# Retry build up to 3 times (Raspbian mirrors can be flaky)
|
||||||
|
MAX_RETRIES=3
|
||||||
|
for attempt in $(seq 1 $MAX_RETRIES); do
|
||||||
|
echo "=== Build attempt $attempt/$MAX_RETRIES ==="
|
||||||
|
|
||||||
|
if ./build.sh; then
|
||||||
|
echo "=== Build succeeded on attempt $attempt ==="
|
||||||
|
mkdir -p /output
|
||||||
|
cp deploy/*.img.xz /output/ 2>/dev/null || cp deploy/*.img /output/ 2>/dev/null
|
||||||
|
ls -lh /output/
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$attempt" -lt "$MAX_RETRIES" ]; then
|
||||||
|
echo "=== Build failed, cleaning up for retry... ==="
|
||||||
|
# Clean debootstrap rootfs so it starts fresh
|
||||||
|
rm -rf work/*/stage0/rootfs 2>/dev/null || true
|
||||||
|
rm -rf work/*/stage0/debootstrap* 2>/dev/null || true
|
||||||
|
sleep 5
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "=== All $MAX_RETRIES attempts failed ==="
|
||||||
|
exit 1
|
||||||
@ -9,7 +9,7 @@ on_chroot << 'CHEOF'
|
|||||||
if ! id tipote &>/dev/null; then
|
if ! id tipote &>/dev/null; then
|
||||||
useradd --system --create-home --home-dir /opt/tipote \
|
useradd --system --create-home --home-dir /opt/tipote \
|
||||||
--shell /usr/sbin/nologin \
|
--shell /usr/sbin/nologin \
|
||||||
--groups dialout,audio \
|
--groups dialout,audio,netdev \
|
||||||
tipote
|
tipote
|
||||||
fi
|
fi
|
||||||
CHEOF
|
CHEOF
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
# /etc/systemd/journald.conf.d/tipote.conf
|
||||||
|
# Limit journal size on Pi Zero 2W (32GB SD card)
|
||||||
|
[Journal]
|
||||||
|
SystemMaxUse=50M
|
||||||
|
SystemMaxFileSize=10M
|
||||||
|
MaxRetentionSec=7day
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "@ti-pote/robot-client",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Ti-Pote Robot Client — Runs on Raspberry Pi Zero 2W",
|
||||||
|
"bin": {
|
||||||
|
"tipote": "./dist/cli.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/main.ts",
|
||||||
|
"build": "tsup src/main.ts src/cli.ts --format esm --dts --clean",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"lint": "eslint \"src/**/*.ts\" --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"hw:demo": "pnpm exec tsx scripts/hardware-demo.ts",
|
||||||
|
"audio:loopback": "pnpm exec tsx scripts/audio-loopback.ts",
|
||||||
|
"audio:beep": "pnpm exec tsx scripts/audio-beep.ts",
|
||||||
|
"esp:record": "pnpm exec tsx ../robot-hardware/scripts/esp-record.ts",
|
||||||
|
"esp:play": "pnpm exec tsx ../robot-hardware/scripts/esp-play.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"pino": "^9.6.0",
|
||||||
|
"pino-pretty": "^13.0.0",
|
||||||
|
"serialport": "^12.0.0",
|
||||||
|
"sd-notify": "^2.8.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"tsup": "^8.5.0",
|
||||||
|
"@types/node": "^22.15.0",
|
||||||
|
"vitest": "^3.2.1",
|
||||||
|
"eslint": "^10.1.0",
|
||||||
|
"prettier": "^3.8.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Ti-Pote Robot Client
|
||||||
|
After=network.target NetworkManager.service
|
||||||
|
Wants=NetworkManager.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=notify
|
||||||
|
ExecStart=/usr/bin/node /opt/tipote/dist/main.js
|
||||||
|
WorkingDirectory=/opt/tipote
|
||||||
|
EnvironmentFile=/opt/tipote/.env
|
||||||
|
User=tipote
|
||||||
|
Group=tipote
|
||||||
|
|
||||||
|
# Restart policy: max 5 restarts per 5 minutes
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StartLimitIntervalSec=300
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
|
# Watchdog: process must ping every 60s or gets killed
|
||||||
|
WatchdogSec=60
|
||||||
|
|
||||||
|
# Logging via journald
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=tipote
|
||||||
|
|
||||||
|
# Security: bind port 80 (captive portal) without root
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
# Resource limits (Pi Zero 2W has 416MB RAM)
|
||||||
|
MemoryMax=200M
|
||||||
|
MemoryHigh=150M
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Loading…
x
Reference in New Issue
Block a user