Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@ FILE_FOLDER=./uploads
MAX_FILE_SIZE=104857600
BODY_SIZE_LIMIT=104857600

GEMINI_API_KEY=your_api_key_here
GEMINI_API_KEY=your_api_key_here

# N.I.N.A config
NINA_BASE_URL=http://10.10.10.211:1888/
UPDATE_THRESHOLD_COUNT=30
14 changes: 14 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,20 @@ Do not declare `children?: Snippet` if it is already part of the extended HTML e

---

### 🏁 Post-Implementation Steps

After every implementation, run the following commands to ensure code quality:

1. `pnpm check`
2. `pnpm lint`

If `pnpm lint` returns unformatted files, run:

1. `pnpm format`
2. `pnpm lint` (again)

---

# Project Architecture

## Overview
Expand Down
6 changes: 3 additions & 3 deletions src/components/Navigation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@
path: '/contact'
},
{
name: _state.lang.navigation.about,
icon: 'bi-info-circle-fill',
path: '/about'
name: _state.lang.navigation.live,
icon: 'bi-circle-fill',
path: '/live-photo'
},
{
name: _state.lang.navigation.login,
Expand Down
23 changes: 22 additions & 1 deletion src/lib/lang/_template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export default o({
admin: _,
login: _,
contact: _,
about: _
about: _,
live: _
}),
adminNavigation: o({
home: _,
Expand All @@ -49,6 +50,26 @@ export default o({
go_home: _,
invalid_type_number: _
}),
live_photo: o({
title: _,
inactive: _,
stats: _,
current_status: _,
mount: _,
image: _,
labels: o({
temp: _,
exposure: _,
ra: _,
dec: _,
gain: _,
target: _,
focal_length: _,
telescope: _,
camera: _,
date: _
})
}),
main: o({
age: _,
text: r
Expand Down
23 changes: 22 additions & 1 deletion src/lib/lang/czech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export default lang.parse({
admin: 'Administrace',
login: 'Přihlášení',
contact: 'Kontakt',
about: 'O mně'
about: 'O mně',
live: 'Astro Okénko'
},
adminNavigation: {
home: 'Panel',
Expand All @@ -27,6 +28,26 @@ export default lang.parse({
go_home: 'Zpátky domů',
invalid_type_number: 'Zadejte platné číslo.'
},
live_photo: {
title: 'Živé foto',
inactive: 'Focení neprobíhá',
stats: 'Statistiky',
current_status: 'Aktuální stav',
mount: 'Montáž',
image: 'Poslední obrázek',
labels: {
temp: 'Teplota',
exposure: 'Expozice',
ra: 'RA',
dec: 'DEC',
gain: 'Gain',
target: 'Cíl',
focal_length: 'Ohnisková vzdálenost',
telescope: 'Teleskop',
camera: 'Kamera',
date: 'Datum'
}
},
main: {
age: 'Věk',
text: [
Expand Down
23 changes: 22 additions & 1 deletion src/lib/lang/english.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export default lang.parse({
admin: 'Administration',
login: 'Login',
contact: 'Contact',
about: 'About Me'
about: 'About Me',
live: 'Astro Window'
},
adminNavigation: {
home: 'Dashboard',
Expand All @@ -27,6 +28,26 @@ export default lang.parse({
go_home: 'Back home',
invalid_type_number: 'Please enter a valid number.'
},
live_photo: {
title: 'Live Photo',
inactive: 'Imaging not active',
stats: 'Statistics',
current_status: 'Current Status',
mount: 'Mount',
image: 'Latest Image',
labels: {
temp: 'Temperature',
exposure: 'Exposure',
ra: 'RA',
dec: 'DEC',
gain: 'Gain',
target: 'Target',
focal_length: 'Focal Length',
telescope: 'Telescope',
camera: 'Camera',
date: 'Date'
}
},
main: {
age: 'Age',
text: [
Expand Down
4 changes: 4 additions & 0 deletions src/lib/server/_routes/telescope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { procedure } from '../api';
import { nina } from '../nina';

export default procedure.GET.query(async () => await nina.getLiveStatus());
194 changes: 194 additions & 0 deletions src/lib/server/nina.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { NINA_BASE_URL, UPDATE_THRESHOLD_COUNT } from './variables';

interface NinaResponse<T> {
Response: T;
Success: boolean;
Error: string;
}

interface ImageHistoryItem {
ExposureTime: number;
Temperature: number;
CameraName: string;
TargetName: string;
Gain: number;
Date: string;
TelescopeName: string;
FocalLength: number;
}

interface MountInfo {
RightAscensionString: string;
DeclinationString: string;
}

interface SequenceItem {
Status: string;
Items?: SequenceItem[];
Name?: string;
Triggers?: SequenceItem[];
}

export type LiveStatus = {
active: boolean;
imageInfo?: ImageHistoryItem;
mountInfo?: MountInfo;
currentAction?: string;
};

export class NinaClient {
private baseUrl: string;
private updateThreshold: number;
private lastUpdate: number = 0;

private cachedLiveStatus: LiveStatus | undefined;

private cachedLiveImage: Buffer | undefined;

constructor() {
this.baseUrl = NINA_BASE_URL ?? 'http://10.10.10.211:1888/';
this.updateThreshold = parseInt(UPDATE_THRESHOLD_COUNT || '30'); // Default 30 seconds
}

private async fetch<T>(endpoint: string): Promise<T | null> {
try {
const res = await fetch(`${this.baseUrl}/v2/${endpoint}`);
if (!res.ok) return null;
return await res.json();
} catch (e) {
// eslint-disable-next-line no-console
console.error(`Error fetching ${endpoint}:`, e);
return null;
}
}

private mapRunningAction(name: string): string {
const map: Record<string, string> = {
'Meridian Flip_Trigger': 'Meridian flip',
'Smart Exposure': 'Exposing',
'Slew and center': 'Slewing',
'Start Guiding': 'Start Guiding',
'Center After Drift_Trigger': 'Slewing',
'Restore Guiding_Trigger': 'Start Guiding',
'AF After HFR Increase_Trigger': 'Focusing',
'Cool Camera': 'Cooling Camera',
'Wait for Time': 'Waiting',
'Wait for Time Span': 'Waiting'
};

return map[name] || name;
}

// Helper to actually implement the recursive search properly aligned with logic
private getDeepestRunningName(items: SequenceItem[]): string | undefined {
for (const item of items) {
// Check if this item is RUNNING
if (item.Status === 'RUNNING') {
// 1. Check Triggers for this item
if (item.Triggers && Array.isArray(item.Triggers)) {
const runningTrigger = item.Triggers.find((t) => t.Status === 'RUNNING');
if (runningTrigger) return runningTrigger.Name;
}

// 2. Check Children Items
if (item.Items && item.Items.length > 0) {
const deepName = this.getDeepestRunningName(item.Items);
if (deepName) return deepName;
}

// 3. If no children/triggers running, this is the deepest running item
return item.Name;
}
}
return undefined;
}

// Re-write of isSequenceRunning to use getDeepestRunningName logic or keep simple?
// We need both active check AND name extraction.

async getLiveStatus() {
const now = Date.now();

// Check threshold with time (seconds * 1000)
// Actually prompt said updateThreshold is in seconds.
const timeDiff = (now - this.lastUpdate) / 1000;

if (this.cachedLiveStatus && timeDiff < this.updateThreshold) {
return this.cachedLiveStatus;
}

// Always check sequence status first
const sequenceData =
await this.fetch<NinaResponse<SequenceItem[]>>('api/sequence/json');

const deepestName =
sequenceData?.Success && sequenceData.Response
? this.getDeepestRunningName(sequenceData.Response)
: undefined;
const isRunning = !!deepestName;

if (!isRunning) {
this.cachedLiveStatus = { active: false };
this.cachedLiveImage = undefined;
return this.cachedLiveStatus;
}

// Sequence is running
const mountData = await this.fetch<NinaResponse<MountInfo>>(
'api/equipment/mount/info'
);

let imageInfo = this.cachedLiveStatus?.imageInfo;

// Update Image Info only if threshold reached or not cached
// NOTE: This logic assumes 'getLiveStatus' is called ~every 30s.
// If we rely on timeDiff for image update:
if (!imageInfo || timeDiff >= this.updateThreshold) {
const imageCount = await this.fetch<NinaResponse<number>>(
'api/image-history?count=true'
);
if (imageCount?.Success) {
const imageData = await this.fetch<NinaResponse<ImageHistoryItem[]>>(
'api/image-history?index=' + (imageCount.Response - 1)
);
if (imageData?.Success && imageData.Response?.length > 0) {
imageInfo = imageData.Response[0];
}
}

// Update Buffer
this.updateImageBuffer();

this.lastUpdate = now;
}

this.cachedLiveStatus = {
active: true,
mountInfo: mountData?.Response,
imageInfo,
currentAction: this.mapRunningAction(deepestName!)
};

return this.cachedLiveStatus;
}

async updateImageBuffer() {
try {
const res = await fetch(`${this.baseUrl}/v2/api/prepared-image`);
if (!res.ok) return;
this.cachedLiveImage = Buffer.from(await res.arrayBuffer());
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error updating image buffer:', e);
}
}

async getLiveImage() {
if (!this.cachedLiveImage) {
await this.updateImageBuffer();
}
return this.cachedLiveImage;
}
}

export const nina = new NinaClient();
4 changes: 3 additions & 1 deletion src/lib/server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ai from './_routes/ai';
import article from './_routes/article';
import equipment from './_routes/equipment';
import login from './_routes/login';
import telescope from './_routes/telescope';
import types from './_routes/types';
import upload from './_routes/upload';
import { router } from './api';
Expand All @@ -12,7 +13,8 @@ export const r = router({
equipment,
upload,
article,
ai
ai,
telescope
});

export type AppRouter = typeof r;
12 changes: 8 additions & 4 deletions src/lib/server/variables.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import type { DB } from '$/types/database';
import { env } from '$env/dynamic/private';
import {
JWT_SECRET,
DATABASE_NAME,
DATABASE_IP,
DATABASE_NAME,
DATABASE_PASSWORD,
DATABASE_PORT,
DATABASE_USER
DATABASE_USER,
JWT_SECRET
} from '$env/static/private';
import { JWTCookies } from './cookies/main';
import { Kysely, MysqlDialect } from 'kysely';
import { createPool } from 'mysql2';
import { JWTCookies } from './cookies/main';

export const NINA_BASE_URL = env.NINA_BASE_URL;
export const UPDATE_THRESHOLD_COUNT = env.UPDATE_THRESHOLD_COUNT;

export const jwt = new JWTCookies(JWT_SECRET);
const dialect = new MysqlDialect({
Expand Down
Loading