diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index adb692665..1cc682b6d 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -1013,6 +1013,34 @@ describe('server', async () => { // and the updated value expect(out).toContain('val:2'); }); + test('preserves blank line between adjacent replaced sections', async () => { + const { replaceOrAddIniSections } = await import('@/server/helpers/file-operations'); + const content = '[a]\nval: 1\n\n[other]\nfoo: bar\n'; + const out = replaceOrAddIniSections(content, [ + { + section: 'a', + body: 'val: replaced\n', + }, + { + section: 'other', + body: 'foo: replaced\nbar: baz\n', + }, + ]); + + expect(out).toContain('val: replaced\n\n[other]'); + }); + test('preserves trailing comments after retained values (including surrounding whitespace) ', async () => { + const { replaceOrAddIniSections } = await import('@/server/helpers/file-operations'); + const content = '[a]\nx: 1 # comment \ny: 2 # blah\n\n[other]\nq: 123\n'; + const out = replaceOrAddIniSections(content, [ + { + section: 'a', + body: 'x: 42\n', + }, + ]); + + expect(out).toContain('x: 42 # comment \n\n[other]'); + }); test('multiple updates for same name: last update wins', async () => { const { replaceOrAddIniSections } = await import('@/server/helpers/file-operations'); const content = ''; @@ -1030,8 +1058,26 @@ describe('server', async () => { const out = replaceOrAddIniSections(content, [{ section: 'section1', body: 'v: new\n' }]); // new body should be present expect(out).toContain('v: new'); - // section2 should still be present - expect(out).toContain('[section2]'); + // comment before section2 should be preserved + expect(out).toContain('# Another comment\n[section2]'); + }); + test('retains trailing comments after replaced sections at end of content', async () => { + const { replaceOrAddIniSections } = await import('@/server/helpers/file-operations'); + const content = '# Start comment\n[section1]\nval: 1\n\n# Another comment\n[section2]\nval: 2\n\n# End comment'; + const out = replaceOrAddIniSections(content, [{ section: 'section2', body: 'v: new\n' }]); + // new body should be present + expect(out).toContain('v: new'); + // comment after section2 should be preserved + expect(out).toContain('v: new\n\n# End comment'); + }); + test('retains trailing comments after unreplaced sections at end of content', async () => { + const { replaceOrAddIniSections } = await import('@/server/helpers/file-operations'); + const content = '# Start comment\n[section1]\nval: 1\n\n# Another comment\n[section2]\nval: 2\n\n# End comment'; + const out = replaceOrAddIniSections(content, [{ section: 'section1', body: 'v: new\n' }]); + // new body should be present + expect(out).toContain('v: new'); + // comment after section 2 should be preserved + expect(out).toContain('val: 2\n\n# End comment'); }); test('idempotent: replacing section with identical content returns identical output', async () => { const { replaceOrAddIniSections } = await import('@/server/helpers/file-operations'); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index a1fb3f1a6..f8bc94f9f 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,3 +1,5 @@ +import type { PermittedServices } from '@/server/helpers/klipper'; + export const register = async () => { if (process.env.NEXT_RUNTIME === 'nodejs') { const { getLogger } = await import('./server/helpers/logger'); @@ -44,7 +46,12 @@ export const register = async () => { }); logger.info('Configuration changed, restarting klipper..'); try { - const restarted = await klipperRestart(); + const servicesToRestart: PermittedServices[] | undefined = res.some( + (r) => r.fileName === 'crowsnest.conf' && (r.action === 'created' || r.action === 'overwritten'), + ) + ? ['crowsnest'] + : undefined; + const restarted = await klipperRestart({ servicesToRestart }); if (restarted) { logger.info('Klipper restarted!'); } else { diff --git a/src/server/helpers/file-operations.ts b/src/server/helpers/file-operations.ts index aab796e09..1541f9232 100644 --- a/src/server/helpers/file-operations.ts +++ b/src/server/helpers/file-operations.ts @@ -330,9 +330,42 @@ export function replaceOrAddIniSections(content: string, updates: IniUpdate[]): if (newBodyTrimmed === originalBodyTrimmed) { out += originalBodyWithTrailing; } else { - // Body changed: use new body and ensure it ends with newline - out += newBody; - if (!out.endsWith('\n')) out += '\n'; + const preserveTrailingInlineComments = (originalBody: string, updatedBody: string) => { + const commentByKey = new Map(); + for (const line of originalBody.split('\n')) { + const match = line.match(/^\s*([^#;\s][^:]*?)\s*:\s*[^\n]*?(\s+[;#].*)$/); + if (!match) continue; + const key = match[1].trim(); + const commentSuffix = match[2]; + if (!commentByKey.has(key)) { + commentByKey.set(key, commentSuffix); + } + } + + return updatedBody + .split('\n') + .map((line) => { + const keyMatch = line.match(/^\s*([^#;\s][^:]*?)\s*:\s*[^\n]*?$/); + if (!keyMatch) return line; + if (line.match(/\s+[;#].*$/)) return line; + const key = keyMatch[1].trim(); + const commentSuffix = commentByKey.get(key); + if (!commentSuffix) return line; + return line + commentSuffix; + }) + .join('\n'); + }; + + const newBodyWithPreservedComments = preserveTrailingInlineComments(originalBodyWithTrailing, newBody); + const trailingDecoration = + originalBodyWithTrailing.match(/(\n(?:(?:[ \t]*(?:[#;].*)?)\n)*(?:[ \t]*(?:[#;].*)?)?)$/)?.[1] ?? ''; + + out += newBodyWithPreservedComments.replace(/\n+$/, ''); + if (trailingDecoration.length > 0) { + out += trailingDecoration; + } else if (!out.endsWith('\n')) { + out += '\n'; + } } } else { // copy original section slice exactly as-is diff --git a/src/server/helpers/klipper.ts b/src/server/helpers/klipper.ts index b5ec62c52..32e184e1b 100644 --- a/src/server/helpers/klipper.ts +++ b/src/server/helpers/klipper.ts @@ -1,9 +1,17 @@ -import { getLogger } from '@/app/_helpers/logger'; +import { getLogger } from '@/server/helpers/logger'; import { getErrorMessage } from '@/utils/exception-handling'; import { MoonrakerPrinterState, MoonrakerPrinterStateErrorEnum, parseMoonrakerHTTPResponse } from '@/zods/moonraker'; -import { get } from 'http'; import { ZodError } from 'zod'; +/** + * Query Moonraker for the printer's current print state. + * + * This function hits Moonraker's `printer/objects/query?print_stats` endpoint and + * returns the `state` field from `print_stats`. + * + * If Moonraker is offline, failing, or returns unexpected data, this function returns + * `'error'`. + */ export const queryPrinterState = async (): Promise< Zod.output['status']['print_stats']['state'] > => { @@ -27,13 +35,34 @@ export const queryPrinterState = async (): Promise< return 'error'; }; -export const klipperRestart = async (force = false) => { - if (force) { +/** + * Restart Klipper (and optionally other services) via Moonraker. + * + * This helper will first check the printer state (unless `force` is true) and + * will only send a restart request if the printer is in an idle/finished/error + * state. When the printer is actively printing, restart will be skipped. + * + * @param opts.force - If true, skip querying printer state and always attempt restarts. + * @param opts.servicesToRestart - Optional list of permitted services to restart before + * triggering the Klipper restart. Service restarts are only attempted if + * the printer state allows for a restart or if `force` is true. + * @param opts.abortOnServiceRestartFailure - If true, abort the whole restart process + * when any service restart request fails. + * @returns `true` when the klipper restart command was successfully sent, `false` otherwise. + * Note that service restart failures do not affect the return value unless + * `abortOnServiceRestartFailure` is set to true. + */ +export const klipperRestart = async (opts?: { + force?: boolean; + servicesToRestart?: PermittedServices[]; + abortOnServiceRestartFailure?: boolean; +}) => { + if (opts?.force === true) { getLogger().info('Restarting Klipper without checking printer state...'); } else { let state: string | undefined; try { - const state = await queryPrinterState(); + state = await queryPrinterState(); } catch (e) { getLogger().error(`Failed to query printer state before Klipper restart: ${getErrorMessage(e)}`); return false; @@ -45,6 +74,16 @@ export const klipperRestart = async (force = false) => { getLogger().info(`Restarting Klipper, printer is currently in '${state}' state...`); } + if (opts?.servicesToRestart && opts.servicesToRestart.length > 0) { + for (const service of opts.servicesToRestart) { + const restarted = await serviceRestart(service); + if (!restarted && opts?.abortOnServiceRestartFailure === true) { + getLogger().error(`Failed to restart service ${service}, aborting Klipper restart.`); + return false; + } + } + } + try { await fetch('http://localhost:7125/printer/restart', { method: 'POST' }); getLogger().info('Klipper restart command sent successfully.'); @@ -55,3 +94,41 @@ export const klipperRestart = async (force = false) => { return false; }; + +// Keep in sync with the expected content of moonraker.asvc (see ratos-common.sh) +export type PermittedServices = + | 'klipper_mcu' + | 'webcamd' + | 'MoonCord' + | 'KlipperScreen' + | 'moonraker-telegram-bot' + | 'moonraker-obico' + | 'sonar' + | 'crowsnest' + | 'octoeverywhere' + | 'ratos-configurator'; + +/** + * Restart a permitted service through Moonraker. + * + * This simply sends a restart request to Moonraker's service API. + * + * @param service - One of the allowed services defined by `PermittedServices`. + * @returns `true` when the restart request was sent successfully, `false` on error. + */ +export const serviceRestart = async (service: PermittedServices) => { + getLogger().info(`Attempting to restart service ${service} via Moonraker...`); + try { + await fetch('http://localhost:7125/machine/services/restart', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service }), + }); + getLogger().info(`${service} restart command sent successfully.`); + return true; + } catch (e) { + getLogger().error(`Failed to send ${service} restart command: ${getErrorMessage(e)}`); + } + + return false; +}; diff --git a/src/server/routers/printer.ts b/src/server/routers/printer.ts index a3ce7e02b..5e0a8f5d6 100644 --- a/src/server/routers/printer.ts +++ b/src/server/routers/printer.ts @@ -76,7 +76,7 @@ import { ToolheadHelper, ToolheadSuffix } from '@/helpers/toolhead'; import { getLastPrinterSettings, hasLastPrinterSettings, savePrinterSettings } from '@/server/helpers/printer-settings'; import { PrinterAxis } from '@/zods/motion'; import { ServerCache, cacheAsyncDirectoryFn } from '@/server/helpers/cache'; -import { klipperRestart } from '@/server/helpers/klipper'; +import { klipperRestart, PermittedServices, serviceRestart } from '@/server/helpers/klipper'; import { access, copyFile, readFile, unlink, writeFile } from 'fs/promises'; import { exec } from 'child_process'; import objectHash from 'object-hash'; @@ -567,6 +567,19 @@ const generateKlipperConfiguration = async ( // At this point we know the file exists. if (file.overwrite) { if (file.exists && file.diskContent === file.content) { + // Ensure that last saved file is up to date. This can happen if the desired content has + // intentionally been read from the current disk content (eg. some of the + // VAOC files do this to achieve "create if it doesn't exist, otherwise keep unchanged" behaviour ). + // If we don't do this, the last saved content can get out of sync with the actual content on disk, + // which can cause confusion later when we compare desired content with last saved content to + // determine if a file has been changed on disk. + if (file.lastSavedContent !== file.content) { + const lastSavedDir = path.dirname(lastSavedPath); + if (!existsSync(lastSavedDir)) { + mkdirSync(lastSavedDir, { recursive: true }); + } + await writeFile(lastSavedPath, file.content); + } return { fileName: file.fileName, action: 'unchanged' }; } // Make a back up. @@ -582,8 +595,8 @@ const generateKlipperConfiguration = async ( ); if (backups.length > 0) { const sortedBackups = backups.sort((a, b) => { - const aDate = new Date(a.split('-').slice(-1)[0].split('.cfg')[0]); - const bDate = new Date(b.split('-').slice(-1)[0].split('.cfg')[0]); + const aDate = new Date(a.split('-').slice(-1)[0].split(fileExt)[0]); + const bDate = new Date(b.split('-').slice(-1)[0].split(fileExt)[0]); return aDate.getTime() - bDate.getTime(); }); if (sortedBackups.length > BACKUPS_TO_KEEP) { @@ -1158,7 +1171,12 @@ export const printerRouter = router({ .mutation(async ({ input }) => { const res = await regenerateKlipperConfiguration(undefined, input.overwriteFiles, input.skipFiles); if (res.some((r) => r.action === 'created' || r.action === 'overwritten')) { - klipperRestart(); + const servicesToRestart: PermittedServices[] | undefined = res.some( + (r) => r.fileName === 'crowsnest.conf' && (r.action === 'created' || r.action === 'overwritten'), + ) + ? ['crowsnest'] + : undefined; + klipperRestart({ servicesToRestart }); } return res; }), @@ -1184,8 +1202,13 @@ export const printerRouter = router({ .mutation(async (ctx) => { const { config: serializedConfig, overwriteFiles, skipFiles } = ctx.input; const config = await deserializePrinterConfiguration(serializedConfig); - const configResult = await generateKlipperConfiguration(config, overwriteFiles, skipFiles); - klipperRestart(); + const configResult = await generateKlipperConfiguration(config, overwriteFiles, skipFiles); + const servicesToRestart: PermittedServices[] | undefined = configResult.some( + (r) => r.fileName === 'crowsnest.conf' && (r.action === 'created' || r.action === 'overwritten'), + ) + ? ['crowsnest'] + : undefined; + klipperRestart({ servicesToRestart }); return configResult; }), flashBeacon: publicProcedure.mutation(async () => { diff --git a/src/templates/toolhead-alignment-systems/rat-rig-vaoc.ts b/src/templates/toolhead-alignment-systems/rat-rig-vaoc.ts index a74ba82ac..dcb313d8b 100644 --- a/src/templates/toolhead-alignment-systems/rat-rig-vaoc.ts +++ b/src/templates/toolhead-alignment-systems/rat-rig-vaoc.ts @@ -16,7 +16,7 @@ export const renderTemplate: RenderTemplateFn = (ctx) => { ctx.extrasGenerator.addFileToRender({ fileName: 'crowsnest.conf', content: updatedCrowsnestContent, - overwrite: false, + overwrite: true, }); return ` ########################### @@ -63,22 +63,20 @@ heater_temp: 50 // If overwrite is requested (or overwrite details are not available), reset dc-endstop.cfg. // Otherwise, use the existing content or the default content if the file does not exist. const dcEndstopCfgFileName = 'ratos_generated/dc-endstop.cfg'; - const forceDcEndstopDefault = ctx.extrasGenerator.isOverwriteRequestedForFile(dcEndstopCfgFileName); - const dcEndstopContent = getDcEndstopConfigurationFileContent(forceDcEndstopDefault, dcEndstopCfgFileName); + const dcEndstopContent = getDcEndstopConfigurationFileContent(dcEndstopCfgFileName); ctx.extrasGenerator.addFileToRender({ fileName: dcEndstopCfgFileName, content: dcEndstopContent, - overwrite: forceDcEndstopDefault, + overwrite: true, }); // Ditto, for adjust-y-max.cfg. const adjustYMaxCfgFileName = 'ratos_generated/adjust-y-max.cfg'; - const forceAdjustYMaxDefault = ctx.extrasGenerator.isOverwriteRequestedForFile(adjustYMaxCfgFileName); - const adjustYMaxContent = getAdjustYMaxConfigurationFileContent(forceAdjustYMaxDefault, adjustYMaxCfgFileName); + const adjustYMaxContent = getAdjustYMaxConfigurationFileContent(adjustYMaxCfgFileName); ctx.extrasGenerator.addFileToRender({ fileName: adjustYMaxCfgFileName, content: adjustYMaxContent, - overwrite: forceAdjustYMaxDefault, + overwrite: true, }); return ` @@ -101,14 +99,6 @@ export function getUpdatedCrowsnestConfigurationForVaoc() { const environment = serverSchema.parse(process.env); const crowsnestPath = path.join(environment.KLIPPER_CONFIG_PATH, 'crowsnest.conf'); return replaceOrAddIniSectionsFromFileSync(crowsnestPath, [ - { - section: 'crowsnest', - body: `log_path: /home/pi/printer_data/logs/crowsnest.log -log_level: verbose -delete_log: false -no_proxy: false -`.trim(), - }, { section: 'cam 1', body: `# Required for Rat Rig VAOC camera integration, DO NOT MODIFY THIS SECTION. @@ -116,7 +106,7 @@ mode: camera-streamer enable_rtsp: false rtsp_port: 8554 port: 8080 -device: /dev/video0 +device: /dev/RatOS/rr-vaoc-camera resolution: 1920x1080 max_fps: 30 `.trim(), @@ -124,16 +114,12 @@ max_fps: 30 ]); } -function getCommonBoilerplateConfigurationFileContent( - forceDefault: boolean, - fileName: string, - macroName: string, -): string { +function getCommonBoilerplateConfigurationFileContent(fileName: string, macroName: string): string { const environment = serverSchema.parse(process.env); const configPath = path.join(environment.KLIPPER_CONFIG_PATH, fileName); const exists = existsSync(configPath); - getLogger().debug(`getting content for ${fileName}, exists=${exists}, forceDefault=${forceDefault}`); - if (forceDefault || !exists) { + getLogger().debug(`getting content for ${fileName}, exists=${exists}`); + if (!exists) { return `# WARNING. THIS FILE IS GENERATED BY RATOS AND # WILL BE UPDATED BY THE ${macroName} MACRO. # DO NOT DELETE OR MODIFY THIS FILE. @@ -142,10 +128,10 @@ function getCommonBoilerplateConfigurationFileContent( return readFileSync(configPath, 'utf-8'); } -function getDcEndstopConfigurationFileContent(forceDefault: boolean, fileName: string): string { - return getCommonBoilerplateConfigurationFileContent(forceDefault, fileName, 'CONFIGURE_DC_ENDSTOP'); +function getDcEndstopConfigurationFileContent(fileName: string): string { + return getCommonBoilerplateConfigurationFileContent(fileName, 'CONFIGURE_DC_ENDSTOP'); } -function getAdjustYMaxConfigurationFileContent(forceDefault: boolean, fileName: string): string { - return getCommonBoilerplateConfigurationFileContent(forceDefault, fileName, 'INCREASE_Y_MAX'); +function getAdjustYMaxConfigurationFileContent(fileName: string): string { + return getCommonBoilerplateConfigurationFileContent(fileName, 'INCREASE_Y_MAX'); }