diff options
| author | Adam Malczewski <[email protected]> | 2026-04-09 15:46:54 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-04-09 15:46:54 +0900 |
| commit | a4b619ff1229b226da3e7228c167aad4289e1784 (patch) | |
| tree | 6a92b7f71dec96f70d4d1e6b9aafe6ce28b596b6 /src/lib/flashair/client.ts | |
| download | flashair-speedsync-a4b619ff1229b226da3e7228c167aad4289e1784.tar.gz flashair-speedsync-a4b619ff1229b226da3e7228c167aad4289e1784.zip | |
inital app skeleton
Diffstat (limited to 'src/lib/flashair/client.ts')
| -rw-r--r-- | src/lib/flashair/client.ts | 198 |
1 files changed, 198 insertions, 0 deletions
diff --git a/src/lib/flashair/client.ts b/src/lib/flashair/client.ts new file mode 100644 index 0000000..4e3a567 --- /dev/null +++ b/src/lib/flashair/client.ts @@ -0,0 +1,198 @@ +import type { FlashAirFileEntry, ThumbnailMeta } from './types'; + +/** Parse a 16-bit FAT date + time into a JS Date. */ +function parseFatDateTime(fatDate: number, fatTime: number): Date { + const year = ((fatDate >> 9) & 0x7f) + 1980; + const month = (fatDate >> 5) & 0x0f; + const day = fatDate & 0x1f; + const hour = (fatTime >> 11) & 0x1f; + const minute = (fatTime >> 5) & 0x3f; + const second = (fatTime & 0x1f) * 2; + return new Date(year, month - 1, day, hour, minute, second); +} + +/** Attribute bit 4 indicates a directory. */ +function isDirectory(attribute: number): boolean { + return (attribute & 0x10) !== 0; +} + +/** + * Resolve the base URL for FlashAir API requests. + * + * In production (served from the card), all requests go to the same origin. + * In development, we use the Vite dev server origin (which can proxy if configured). + */ +function getBaseUrl(): string { + return ''; +} + +/** + * Parse the text response from command.cgi op=100 into structured entries. + * + * First line is the header "WLANSD_FILELIST" and is skipped. + * Each subsequent line: <directory>,<filename>,<size>,<attribute>,<date>,<time> + * Note: filenames may contain commas, so we split from the right for the numeric fields. + */ +function parseFileList(text: string): FlashAirFileEntry[] { + const lines = text.trim().split('\n'); + const entries: FlashAirFileEntry[] = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined || line.trim() === '') continue; + + // Split from the right: the last 4 fields are always numeric (size, attribute, date, time). + // The first field is directory, and everything between is the filename (which may contain commas). + const parts = line.split(','); + if (parts.length < 6) continue; + + const rawTime = Number(parts[parts.length - 1]); + const rawDate = Number(parts[parts.length - 2]); + const attribute = Number(parts[parts.length - 3]); + const size = Number(parts[parts.length - 4]); + const directory = parts[0] ?? ''; + // Filename is everything between the first and last 4 fields + const filename = parts.slice(1, parts.length - 4).join(','); + + const date = parseFatDateTime(rawDate, rawTime); + const dir = isDirectory(attribute); + const path = directory === '' ? `/${filename}` : `${directory}/${filename}`; + + entries.push({ + directory, + filename, + size, + attribute, + rawDate, + rawTime, + date, + isDirectory: dir, + path, + }); + } + + return entries; +} + +/** Known image extensions the FlashAir thumbnail.cgi supports (JPEG only). */ +const JPEG_EXTENSIONS = new Set(['.jpg', '.jpeg']); + +function isJpeg(filename: string): boolean { + const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase(); + return JPEG_EXTENSIONS.has(ext); +} + +/** + * FlashAir API client. + * + * All methods return promises and throw on network/HTTP errors. + */ +export const flashair = { + /** + * List files in a directory. + * @param dir - Absolute path on the card, e.g. "/DCIM" + */ + async listFiles(dir: string): Promise<FlashAirFileEntry[]> { + const base = getBaseUrl(); + const res = await fetch(`${base}/command.cgi?op=100&DIR=${encodeURIComponent(dir)}`); + if (!res.ok) throw new Error(`listFiles failed: ${res.status} ${res.statusText}`); + const text = await res.text(); + return parseFileList(text); + }, + + /** + * Get the number of files in a directory. + */ + async getFileCount(dir: string): Promise<number> { + const base = getBaseUrl(); + const res = await fetch(`${base}/command.cgi?op=101&DIR=${encodeURIComponent(dir)}`); + if (!res.ok) throw new Error(`getFileCount failed: ${res.status} ${res.statusText}`); + const text = await res.text(); + return Number(text.trim()); + }, + + /** + * Check if the card memory has been updated since last check. + * Returns true if updated. Calling this clears the flag. + */ + async hasUpdated(): Promise<boolean> { + const base = getBaseUrl(); + const res = await fetch(`${base}/command.cgi?op=102`); + if (!res.ok) throw new Error(`hasUpdated failed: ${res.status} ${res.statusText}`); + const text = await res.text(); + return text.trim() === '1'; + }, + + /** + * Get the firmware version string. + */ + async getFirmwareVersion(): Promise<string> { + const base = getBaseUrl(); + const res = await fetch(`${base}/command.cgi?op=108`); + if (!res.ok) throw new Error(`getFirmwareVersion failed: ${res.status} ${res.statusText}`); + return (await res.text()).trim(); + }, + + /** + * Get SSID of the FlashAir. + */ + async getSSID(): Promise<string> { + const base = getBaseUrl(); + const res = await fetch(`${base}/command.cgi?op=104`); + if (!res.ok) throw new Error(`getSSID failed: ${res.status} ${res.statusText}`); + return (await res.text()).trim(); + }, + + /** + * Get the number of free sectors on the card. + */ + async getFreeSectors(): Promise<number> { + const base = getBaseUrl(); + const res = await fetch(`${base}/command.cgi?op=140`); + if (!res.ok) throw new Error(`getFreeSectors failed: ${res.status} ${res.statusText}`); + return Number((await res.text()).trim()); + }, + + /** + * Get the URL for a file on the card. + */ + fileUrl(path: string): string { + return `${getBaseUrl()}${path}`; + }, + + /** + * Get the thumbnail URL for a JPEG file. + * Returns undefined if the file is not a JPEG (thumbnails only work for JPEGs). + */ + thumbnailUrl(path: string): string | undefined { + const filename = path.slice(path.lastIndexOf('/') + 1); + if (!isJpeg(filename)) return undefined; + return `${getBaseUrl()}/thumbnail.cgi?${path}`; + }, + + /** + * Fetch a thumbnail and its EXIF metadata. + * Returns the blob and optional EXIF dimensions/orientation. + */ + async fetchThumbnail(path: string): Promise<{ blob: Blob; meta: ThumbnailMeta }> { + const url = flashair.thumbnailUrl(path); + if (url === undefined) throw new Error(`Not a JPEG file: ${path}`); + const res = await fetch(url); + if (!res.ok) throw new Error(`fetchThumbnail failed: ${res.status} ${res.statusText}`); + const blob = await res.blob(); + + const widthStr = res.headers.get('X-exif-WIDTH'); + const heightStr = res.headers.get('X-exif-HEIGHT'); + const orientStr = res.headers.get('X-exif-ORIENTATION'); + + const meta: ThumbnailMeta = { + width: widthStr !== null ? Number(widthStr) : undefined, + height: heightStr !== null ? Number(heightStr) : undefined, + orientation: orientStr !== null ? Number(orientStr) : undefined, + }; + + return { blob, meta }; + }, +} as const; + +export type { FlashAirFileEntry, ThumbnailMeta } from './types'; |
