SLOPSHOPPER

harmony-remote

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

newbandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · harmony-remote
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ── Harmony remote ─────────────────────────────────────────────────────────────────────────────────────────────────… no remote on USB ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
── Harmony remote ─────────────────────────────────────────────────────────────────────────────────… no remote on USB
README

harmony-explorations

Logitech switched off the software that programmed these remotes. This project is working out how to program them without it.

If you came here looking for a replacement

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.

What we are trying to build

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.

Where we stand

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 remoteWorks. What comes off matches a backup of that unit byte for byte
Work out what is in itDone 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 namesWorks. 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 identicallyWorks. This is the test that has to pass before it is safe to change anything
Change a configuration on the computerWorks. 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 remoteStarted. 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 remoteHalf 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.

The details, for anyone who wants them

docs/status.mdwhere the work stands today
docs/findings.mdevery finding, numbered, with the evidence for it
docs/config-format.mdthe file format written up as a specification
todo.mdthe 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.

Contributing

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.

Backing up your own remote

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.

Working on the code

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.

Licence

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.

Source 2 files
hooks/register.tsx 216 lines
1import { 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}
216
types/index.d.ts 13 lines
1// 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