From 6514e3a7a917262d26258fda3cfe7910537f5c20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Mint=C4=9Bl?= Date: Sun, 18 Jan 2026 22:12:38 +0100 Subject: [PATCH 1/3] tool: updated copilot instructions --- .github/copilot-instructions.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e86fb73..0eb2e64 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 From e9429a927f678e0f9afbb47fe8c408f3f684c52b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Mint=C4=9Bl?= Date: Sun, 18 Jan 2026 22:19:39 +0100 Subject: [PATCH 2/3] feat: added live astro window --- .env.example | 6 +- src/components/Navigation.svelte | 6 +- src/lib/lang/_template.ts | 23 ++- src/lib/lang/czech.ts | 23 ++- src/lib/lang/english.ts | 23 ++- src/lib/server/_routes/telescope.ts | 4 + src/lib/server/nina.ts | 193 ++++++++++++++++++ src/lib/server/routes.ts | 4 +- src/lib/server/variables.ts | 12 +- .../[[lang=lang]]/live-photo/+page.svelte | 154 ++++++++++++++ src/routes/api/live-image/+server.ts | 17 ++ 11 files changed, 453 insertions(+), 12 deletions(-) create mode 100644 src/lib/server/_routes/telescope.ts create mode 100644 src/lib/server/nina.ts create mode 100644 src/routes/[[lang=lang]]/live-photo/+page.svelte create mode 100644 src/routes/api/live-image/+server.ts diff --git a/.env.example b/.env.example index 4618338..11f125a 100644 --- a/.env.example +++ b/.env.example @@ -22,4 +22,8 @@ FILE_FOLDER=./uploads MAX_FILE_SIZE=104857600 BODY_SIZE_LIMIT=104857600 -GEMINI_API_KEY=your_api_key_here \ No newline at end of file +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 diff --git a/src/components/Navigation.svelte b/src/components/Navigation.svelte index ceec170..be4ad94 100644 --- a/src/components/Navigation.svelte +++ b/src/components/Navigation.svelte @@ -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, diff --git a/src/lib/lang/_template.ts b/src/lib/lang/_template.ts index 8affa26..8a21d62 100644 --- a/src/lib/lang/_template.ts +++ b/src/lib/lang/_template.ts @@ -35,7 +35,8 @@ export default o({ admin: _, login: _, contact: _, - about: _ + about: _, + live: _ }), adminNavigation: o({ home: _, @@ -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 diff --git a/src/lib/lang/czech.ts b/src/lib/lang/czech.ts index ad84cdc..bc68817 100644 --- a/src/lib/lang/czech.ts +++ b/src/lib/lang/czech.ts @@ -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', @@ -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: [ diff --git a/src/lib/lang/english.ts b/src/lib/lang/english.ts index f26488b..caf7e0e 100644 --- a/src/lib/lang/english.ts +++ b/src/lib/lang/english.ts @@ -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', @@ -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: [ diff --git a/src/lib/server/_routes/telescope.ts b/src/lib/server/_routes/telescope.ts new file mode 100644 index 0000000..236c5b1 --- /dev/null +++ b/src/lib/server/_routes/telescope.ts @@ -0,0 +1,4 @@ +import { procedure } from '../api'; +import { nina } from '../nina'; + +export default procedure.GET.query(async () => await nina.getLiveStatus()); diff --git a/src/lib/server/nina.ts b/src/lib/server/nina.ts new file mode 100644 index 0000000..46d6a89 --- /dev/null +++ b/src/lib/server/nina.ts @@ -0,0 +1,193 @@ +import { NINA_BASE_URL, UPDATE_THRESHOLD_COUNT } from './variables'; + +interface NinaResponse { + 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(endpoint: string): Promise { + 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 = { + '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>('api/sequence/json'); + + const deepestName = + sequenceData?.Success && sequenceData.Response + ? this.getDeepestRunningName(sequenceData.Response) + : undefined; + const isRunning = !!deepestName; + + if (!isRunning) { + this.cachedLiveStatus = { active: false }; + return this.cachedLiveStatus; + } + + // Sequence is running + const mountData = await this.fetch>( + '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>( + 'api/image-history?count=true' + ); + if (imageCount?.Success) { + const imageData = await this.fetch>( + '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(); diff --git a/src/lib/server/routes.ts b/src/lib/server/routes.ts index 2cf517a..070d6d9 100644 --- a/src/lib/server/routes.ts +++ b/src/lib/server/routes.ts @@ -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'; @@ -12,7 +13,8 @@ export const r = router({ equipment, upload, article, - ai + ai, + telescope }); export type AppRouter = typeof r; diff --git a/src/lib/server/variables.ts b/src/lib/server/variables.ts index 9822a9f..f124acc 100644 --- a/src/lib/server/variables.ts +++ b/src/lib/server/variables.ts @@ -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({ diff --git a/src/routes/[[lang=lang]]/live-photo/+page.svelte b/src/routes/[[lang=lang]]/live-photo/+page.svelte new file mode 100644 index 0000000..270e168 --- /dev/null +++ b/src/routes/[[lang=lang]]/live-photo/+page.svelte @@ -0,0 +1,154 @@ + + +

{appState.lang.live_photo.title}

+ +
+
+ {#if liveData?.active} + Live view + {:else} +
+ {appState.lang.live_photo.inactive} +
+ {/if} +
+ +
+

+ {appState.lang.live_photo.stats} +

+ +
+ +
+

+ {appState.lang.live_photo.current_status} +

+

+ {liveData?.currentAction || '-'} +

+
+ + +
+

Mount

+
+ {appState.lang.live_photo.labels.ra}: + {liveData?.mountInfo?.RightAscensionString || '-'} + + {appState.lang.live_photo.labels.dec}: + {liveData?.mountInfo?.DeclinationString || '-'} +
+
+ + +
+

Last Image

+
+ {appState.lang.live_photo.labels.target}: + {liveData?.imageInfo?.TargetName || 'No info'} + + {appState.lang.live_photo.labels.date}: + {liveData?.imageInfo?.Date + ? new Date(liveData.imageInfo.Date).toLocaleTimeString() + : '-'} + + {appState.lang.live_photo.labels.exposure}: + {liveData?.imageInfo?.ExposureTime + ? `${liveData.imageInfo.ExposureTime}s` + : '-'} + + {appState.lang.live_photo.labels.temp}: + {liveData?.imageInfo?.Temperature + ? `${liveData.imageInfo.Temperature}°C` + : '-'} + + {appState.lang.live_photo.labels.gain}: + {liveData?.imageInfo?.Gain ?? '-'} + + {appState.lang.live_photo.labels.focal_length}: + {liveData?.imageInfo?.FocalLength + ? `${liveData.imageInfo.FocalLength}mm` + : '-'} + + {appState.lang.live_photo.labels.telescope}: + {liveData?.imageInfo?.TelescopeName || 'No info'} + + {appState.lang.live_photo.labels.camera}: + {liveData?.imageInfo?.CameraName || 'No info'} +
+
+
+
+
diff --git a/src/routes/api/live-image/+server.ts b/src/routes/api/live-image/+server.ts new file mode 100644 index 0000000..6575968 --- /dev/null +++ b/src/routes/api/live-image/+server.ts @@ -0,0 +1,17 @@ +import { nina } from '$/lib/server/nina'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + const image = await nina.getLiveImage(); + + if (!image) { + return new Response('Not found', { status: 404 }); + } + + return new Response(image, { + headers: { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'no-cache, no-store, must-revalidate' + } + }); +}; From 267d310a1e91c022fd888693b497a69825b61d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Mint=C4=9Bl?= Date: Sun, 18 Jan 2026 22:28:38 +0100 Subject: [PATCH 3/3] fix: Remove cached image, if telescope became inactive Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/lib/server/nina.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/server/nina.ts b/src/lib/server/nina.ts index 46d6a89..98ff982 100644 --- a/src/lib/server/nina.ts +++ b/src/lib/server/nina.ts @@ -129,6 +129,7 @@ export class NinaClient { if (!isRunning) { this.cachedLiveStatus = { active: false }; + this.cachedLiveImage = undefined; return this.cachedLiveStatus; }