A band above the prompt: is a Harmony remote on USB and ready, and how far a configuration write to it has got

Logitech switched off the software that programmed these remotes. This project is working out how to program them without it.
On 28 May 2025 Logitech discontinued the Harmony Remote Software, the desktop program that 40 of these remotes were set up with. A remote that already works keeps working, because everything it needs is stored inside it. What you can no longer do is change it: add a device, rename an activity, teach it a button off an old remote. There is nothing left to do that with.
The newer service, MyHarmony, is still running as of August 2026 and still programmes the remotes it supports. It does not cover the older models at all, and it is somebody else's server, so how long it stays up is not up to you.
So the position is simple. The configuration sitting on your remote can be read off it. Nobody outside Logitech can make a new one.
FreeHarmony: a program on your own computer that reads the configuration off your remote, lets you change your devices and activities, learns codes from your old remotes, and writes it back. Nothing hosted, nothing that can be switched off later.
It has begun and there is nothing to install yet. As of 25 August 2026 it is a desktop application taking shape: it opens, keeps your remotes in one place, and reads a configuration file into the devices and activities it holds, so the seam between the two repositories is proven by something that runs. Everything else about it is still ahead, and it cannot be written until the file a remote stores is properly understood. Working that out is what this repository is for: the understanding, and the code that does the reading.
The first version will only read, never write. These remotes cannot be bought new and a bad write can turn one into a brick, so the first thing this project ever writes to a remote will not be a guess.
Nine remotes are on the bench: a Harmony One, a second One kept as a spare, a Harmony 600, a Harmony 525, a Harmony Touch, a Harmony 350 and a Harmony 300, and later a Harmony 650 and a Harmony 700, plus configuration files that other owners have sent in. The work is about the Harmony Ones, the 525, the 600, the 650 and the 700; the Touch, the 350 and the 300 speak a different protocol and are only partly reachable.
| Read the whole configuration off a remote | Works. What comes off matches a backup of that unit byte for byte |
| Work out what is in it | Done to the last byte for the four remote families the tools cover, and nearly so for the fifth |
| List your devices and activities, with their names | Works. The names are recovered from the pictures the remote draws on its own screen, because that is the only place it keeps them |
| Take a configuration apart and rebuild it identically | Works. This is the test that has to pass before it is safe to change anything |
| Change a configuration on the computer | Works. Small edits, bigger ones that move everything after them, and adding a whole device from Logitech's catalogue, whose infrared comes out byte for byte what Logitech's own service would have written |
| Write it back to the remote | Started. A remote's own settings have been written back to it unchanged, which proves the mechanism without risking anything. Writing something you actually changed is the next step, and it stays switched off until the way back from a mistake is proven |
| Learn a code from an old remote | Half built: turning a known code into pulses works and is checked against Logitech's own compiler; capturing one from a real remote is read but not built |
One write has been performed here, and it changed nothing. On 30 August 2026 a small part of a spare remote's own settings was erased and written straight back, unchanged, and the remote afterwards was exactly as it started. Everything else that has ever happened here is reading, and the code refuses to write at all unless somebody deliberately turns that on.
| docs/status.md | where the work stands today |
| docs/findings.md | every finding, numbered, with the evidence for it |
| docs/config-format.md | the file format written up as a specification |
| todo.md | the plan, and the decisions behind it |
The analysis was produced by an AI and is published as such, so all of it is written to be checked rather than trusted: every conclusion carries a test that fails if it stops being true, and the mistakes are corrected in the open, where they happened, so the rest can be judged against them.
Configuration files are not being collected at the moment. More files would answer questions about models nobody here owns, and the thing that matters next is getting FreeHarmony working on the remotes that are already here. When there is an application for the files to be useful to, that changes.
Issues and discussions are very welcome for anything else, especially a reading here that disagrees with what your own remote does. Please do not attach a configuration or a firmware file to an issue: this repository is public and neither can be published in it.
Worth doing now, whatever happens to this project, because a remote that loses its configuration cannot be given a new one. concordance is an existing command line tool that reads one off:
concordance -c my-remote-config.EZHex # the configuration
concordance -f my-remote-firmware.bin # the firmware
One warning is worth more than all the rest: use lower case flags only. -c reads the configuration off the remote and -C writes one to it, -f reads the firmware and -F overwrites it. The flag you want and the flag that reflashes your remote differ by one shift key, and a bare filename with no flag at all makes concordance decide for itself, which for a configuration means writing it.
Python 3 for the analysis, Node 24 for the rest. Nothing else has to be installed.
make all # the test suites and the document checks
make coverage # how much of each configuration file is understood
Configuration and firmware files are not in this repository, so the tests that need them skip without them. CLAUDE.md is the working brief and describes the layout and the conventions in full.
MIT, see LICENSE. Logitech and Harmony are trademarks of Logitech International S.A., used here only to say which hardware this is about. This project is not affiliated with Logitech.
hooks/register.tsx 216 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4// One line for the bench, drawn in the band above the prompt: whether a Harmony remote is on USB and ready, and how far a
5// configuration write to it has got. Everything here is read only: it asks the operating system what
6// is attached (ioreg, enumeration, never an open) and reads the journal the config writer appends to
7// beside the configuration it writes, so it can never touch a remote itself.
8//
9// It needs no lab path. The writer names its journal `<config>.write-<time>.log`, so the running
10// writer's own command line and working directory say where the journal is. Once the writer has
11// exited the journal is remembered from the last poll that saw it, which is what keeps "done" and
12// "stopped" on the line after the process is gone.
13
14// Drawn in the band above the prompt rather than on the status line, because the status line takes
15// text only and its colour is the engine's, which reads poorly on a light theme.
16const line = atom({ plugin: 'harmony-remote', key: 'line' } as const, null)
17const theme = atom({ plugin: 'harmony-remote', key: 'theme' } as const, '')
18
19// Blue on a light theme and the theme's own orange on a dark one. A theme of "auto" follows the
20// terminal, which a plugin cannot see, so it gets the orange: the theme key resolves per theme and
21// stays legible on both, where a fixed blue would not on a dark background.
22export function bandColour(themeSetting: string): string {
23 return themeSetting.startsWith('light') ? '#1f5fbf' : 'claude'
24}
25
26const POLL_MS = 3000
27// A finished or stopped write stays on the line this many polls, two minutes, so it is seen after
28// looking away.
29const LINGER_POLLS = 40
30
31// The bracket keeps a pattern from matching the shell that runs it: the shell's own command line
32// holds `confi[g]`, which the expression does not match, where a plain `config` would make every
33// poll find a writer.
34const WRITER = 'corpus/bin/write-confi[g][.]ts'
35const READER = 'corpus/bin/read-(regio[n]|confi[g])[.]ts'
36
37// Logitech's Harmony product range and Microchip's bootloader identity, which is what a Harmony in
38// recovery enumerates as (harmony-explorations packages/usb/src/transport.ts).
39const LOGITECH = 1133
40const FIRST = 0xc110
41const LAST = 0xc14f
42const MICROCHIP = 0x04d8
43const BOOTLOADER = 0x000b
44const MODELS: Record<number, string> = {
45 0xc121: 'Harmony One',
46 0xc122: 'Harmony 600/650/700',
47 0xc124: 'Harmony 300/350',
48 0xc12b: 'Harmony Touch',
49}
50
51/** The numeric properties of each device block in an ioreg listing. */
52export function ioregBlocks(text: string): Map<string, string>[] {
53 return text.split('+-o ').slice(1).map((block) => {
54 const props = new Map<string, string>()
55 for (const m of block.matchAll(/"([A-Za-z ]+)" = ("[^"]*"|\d+)/g)) {
56 if (!props.has(m[1]!)) props.set(m[1]!, m[2]!.replace(/^"|"$/g, ''))
57 }
58 return props
59 })
60}
61
62const isHarmony = (vendor: number, product: number): boolean =>
63 vendor === LOGITECH && product >= FIRST && product <= LAST
64
65/** What the bus says: a Harmony on USB, whether its command interface is up, or one in recovery. */
66export function usbState(usb: string, hid: string): string {
67 const onBus = ioregBlocks(usb).map((p) => [Number(p.get('idVendor')), Number(p.get('idProduct'))] as const)
68 if (onBus.some(([v, p]) => v === MICROCHIP && p === BOOTLOADER)) return 'a remote in recovery (bootloader)'
69 const harmony = onBus.find(([v, p]) => isHarmony(v, p))
70 if (harmony === undefined) return 'no remote on USB'
71 const name = MODELS[harmony[1]] ?? `Harmony 0x${harmony[1].toString(16)}`
72 // The device can be on the bus while its HID interface is not up yet, or no longer: that is the
73 // state in which every read here answers "no matching Harmony remote attached".
74 const ready = ioregBlocks(hid).some((p) => isHarmony(Number(p.get('VendorID')), Number(p.get('ProductID'))))
75 return ready ? `${name} on USB, ready` : `${name} on USB, not answering yet`
76}
77
78export type WriteProgress = {
79 unit: string
80 total: number
81 erased: number
82 isDryRun: boolean
83 isVerified: boolean
84 isDone: boolean
85}
86
87/** How far a config write's journal says it got. */
88export function writeProgress(journal: string): WriteProgress {
89 return {
90 unit: /matches the recorded (\S+)/.exec(journal)?.[1] ?? 'remote',
91 total: Number(/in (\d+) block\(s\)/.exec(journal)?.[1] ?? 0),
92 erased: (journal.match(/^erasing 0x/gm) ?? []).length,
93 isDryRun: /^dry run:/m.test(journal),
94 isVerified: /reads back byte for byte identical/.test(journal),
95 isDone: /the restart is sent/.test(journal),
96 }
97}
98
99/** The status line for a write, given whether the writer is still running. */
100export function writeLine(w: WriteProgress, isRunning: boolean): string | undefined {
101 if (w.isDryRun) return undefined
102 if (w.isDone) return `${w.unit}: write done, verified, restarted`
103 if (w.isVerified) return `${w.unit}: written and verified, restarting`
104 if (!isRunning) return `${w.unit}: write STOPPED after ${w.erased} of ${w.total} blocks, rerun it`
105 if (w.total > 0 && w.erased === 0) return `${w.unit}: write starting, comparing ${w.total} blocks`
106 if (w.erased >= w.total && w.total > 0) return `${w.unit}: reading the whole configuration back`
107 return `${w.unit}: writing block ${w.erased} of ${w.total}`
108}
109
110/**
111 * The configuration a writer was started on, as an absolute path, from its command line and its
112 * working directory, or undefined when the command line names none.
113 */
114export function configOf(args: string, cwd: string): string | undefined {
115 const config = /--config[ =](\S+)/.exec(args)?.[1]
116 if (config === undefined) return undefined
117 return config.startsWith('/') ? config : `${cwd.replace(/\/$/, '')}/${config}`
118}
119
120const quote = (s: string): string => `'${s.replace(/'/g, `'\\''`)}'`
121
122export const register: Register = (on) => {
123 on('config.set', { key: 'theme' }, async ($, e, next) => {
124 const result = await next(e)
125 await update($, theme, () => String(e.value))
126 return result
127 // Not a guard: the setting has changed before this hook does anything, so a failure here only
128 // leaves the band in the old colour, and the change itself must go through either way.
129 }).catch(($, e, next) => next(e))
130
131 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
132 const text = await read($, line)
133 if (e.props.hasSurvey || text === null) return next(e)
134 const { Box, Text } = $.ui.resolve(e)
135 // A rule above the line, so it reads as a panel of its own rather than as the tail of whatever
136 // notice the engine printed last. The rule is longer than any terminal and cut at the edge.
137 return (
138 <Box flexDirection="column" marginTop={1}>
139 <Text dimColor wrap="truncate">{'── Harmony remote ' + '─'.repeat(400)}</Text>
140 <Box paddingLeft={2}>
141 <Text color={bandColour(await read($, theme))} wrap="truncate-end">{text}</Text>
142 </Box>
143 </Box>
144 )
145 })
146
147 on('session.start', async ($, e, next) => {
148 try {
149 const row = (await $.config.list()).find((r) => r.key === 'theme')
150 await update($, theme, () => String(row?.value ?? ''))
151 } catch { /* no theme row: the band uses the orange */ }
152 let last: string | undefined
153 let busy = false
154 // The journal of the most recent write seen running, and how many polls ago it stopped.
155 let journal: string | undefined
156 let stoppedPolls = 0
157
158 const sh = async (script: string): Promise<string> => {
159 try {
160 const r = await $.process.run(['/bin/sh', '-c', script], { timeoutMs: 10_000 })
161 return r.stdout
162 } catch {
163 return ''
164 }
165 }
166
167 const poll = async (): Promise<void> => {
168 if (busy) return
169 busy = true
170 try {
171 const [usb, hid, writer, reader] = await Promise.all([
172 sh('ioreg -rc IOUSBHostDevice -w0'),
173 sh('ioreg -rc IOHIDInterface -w0'),
174 // The running writer's working directory on the first line and its command line on the
175 // second, or nothing when no write is running.
176 sh(`pid=$(pgrep -f '${WRITER}' | head -1); [ -n "$pid" ] && {`
177 + ` lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p'; ps -ww -o args= -p "$pid"; }`),
178 sh(`pgrep -f '${READER}' >/dev/null && echo yes`),
179 ])
180 const parts = [usbState(usb, hid)]
181 const [cwd, args] = writer.trim().split('\n')
182 const config = cwd !== undefined && args !== undefined ? configOf(args, cwd) : undefined
183 const isWriting = config !== undefined
184 if (config !== undefined) {
185 const newest = (await sh(`ls -t ${quote(config)}.write-*.log 2>/dev/null | head -1`)).trim()
186 if (newest !== '') journal = newest
187 stoppedPolls = 0
188 } else {
189 stoppedPolls += 1
190 }
191 if (journal !== undefined && (isWriting || stoppedPolls <= LINGER_POLLS)) {
192 let text = ''
193 try { text = await $.fs.read(journal) } catch { /* moved or deleted: show nothing */ }
194 const line = text === '' ? undefined : writeLine(writeProgress(text), isWriting)
195 if (line !== undefined) parts.push(line)
196 }
197 if (reader.trim() === 'yes') parts.push('reading flash')
198 const text = parts.join(' | ')
199 if (text !== last) {
200 // A toast on the two moments worth looking up for, once each.
201 if (/write done/.test(text) && !/write done/.test(last ?? '')) $.ui.toast('Harmony write done and verified')
202 if (/STOPPED/.test(text) && !/STOPPED/.test(last ?? '')) $.ui.toast('Harmony write stopped before it finished')
203 last = text
204 await update($, line, () => text)
205 }
206 } finally {
207 busy = false
208 }
209 }
210
211 await poll()
212 $.clock.every(POLL_MS, () => { void poll() })
213 return next(e)
214 })
215}
216types/index.d.ts 13 lines1// The line the band shows, or null before the first poll has finished.
2export type BandLine = string | null
3
4declare module 'claude-code' {
5 interface PluginState {
6 'harmony-remote': {
7 line: BandLine
8 // The theme setting's value, which picks the band's colour.
9 theme: string
10 }
11 }
12}
13