diff --git a/package.json b/package.json index 47d873b..69ecb69 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "eventemitter3": "^4.0.7", "exit-hook": "^2.2.1", "infinitton-idisplay": "^1.1.2", + "loupedeck": "github:julusian/loupedeck#master", "meow": "^9.0.0", "node-hid": "github:julusian/node-hid#v2.1.2-1", "sharp": "^0.30.7", diff --git a/src/cards.ts b/src/cards.ts index 164748b..34aebbb 100644 --- a/src/cards.ts +++ b/src/cards.ts @@ -46,6 +46,7 @@ export class CardGenerator { left: 10, }, ]) + .removeAlpha() .toBuffer() } } diff --git a/src/device-types/loupedeck.ts b/src/device-types/loupedeck.ts new file mode 100644 index 0000000..103a242 --- /dev/null +++ b/src/device-types/loupedeck.ts @@ -0,0 +1,226 @@ +import { LoupedeckDevice, LoupedeckButtonId, LoupedeckKnobId } from 'loupedeck' +import sharp = require('sharp') +import { CompanionSatelliteClient } from '../client' +import { CardGenerator } from '../cards' +import { ImageWriteQueue } from '../writeQueue' +import { DeviceDrawProps, DeviceRegisterProps, WrappedDevice } from './api' + +export class LoupedeckWrapper implements WrappedDevice { + readonly #cardGenerator: CardGenerator + readonly #deck: LoupedeckDevice + readonly #deviceId: string + + #queueOutputId: number + #queue: ImageWriteQueue + + public get deviceId(): string { + return this.#deviceId + } + public get productName(): string { + return `Satellite Loupedeck Live` + } + + public constructor(deviceId: string, device: LoupedeckDevice, cardGenerator: CardGenerator) { + this.#deck = device + this.#deviceId = deviceId + this.#cardGenerator = cardGenerator + + this.#queueOutputId = 0 + + this.#cardGenerator + + this.#queue = new ImageWriteQueue(async (key: number, buffer: Buffer) => { + if (key > 40) { + return + } + + const outputId = this.#queueOutputId + + const width = 80 + const height = 80 + + let newbuffer: Buffer | null = null + try { + newbuffer = await sharp(buffer, { raw: { width: 72, height: 72, channels: 3 } }) + .resize(width, height) + .raw() + .toBuffer() + } catch (e) { + console.log(`device(${deviceId}): scale image failed: ${e}`) + return + } + + // Check if generated image is still valid + if (this.#queueOutputId === outputId) { + try { + // Get offset x/y for key index + const x = (key % 4) * 90 + const y = Math.floor(key / 4) * 90 + + await this.#deck.drawBuffer({ + id: 'center', + width, + height, + x: x + (90 - width) / 2, + y: y + (90 - height) / 2, + buffer: newbuffer, + }) + } catch (e_1) { + console.error(`device(${deviceId}): fillImage failed: ${e_1}`) + } + } + }) + } + + getRegisterProps(): DeviceRegisterProps { + return { + keysTotal: 32, + keysPerRow: 8, + bitmaps: true, + colours: true, + text: true, + } + } + + async close(): Promise { + this.#queue?.abort() + this.#deck.close() + } + async initDevice(client: CompanionSatelliteClient, status: string): Promise { + const convertButtonId = (id: LoupedeckButtonId | LoupedeckKnobId): number => { + if (!isNaN(Number(id))) { + return 24 + Number(id) + } else if (id === 'circle') { + return 24 + } else if (id === 'knobTL') { + return 1 + } else if (id === 'knobCL') { + return 9 + } else if (id === 'knobBL') { + return 17 + } else if (id === 'knobTR') { + return 6 + } else if (id === 'knobCR') { + return 14 + } else if (id === 'knobBR') { + return 22 + } else { + // Discard + return 99 + } + } + console.log('Registering key events for ' + this.deviceId) + this.#deck.on('down', ({ id }) => client.keyDown(this.deviceId, convertButtonId(id))) + this.#deck.on('up', ({ id }) => client.keyUp(this.deviceId, convertButtonId(id))) + this.#deck.on('rotate', ({ id, delta }) => { + let id2 + if (id === 'knobTL') { + id2 = 0 + } else if (id === 'knobCL') { + id2 = 8 + } else if (id === 'knobBL') { + id2 = 16 + } else if (id === 'knobTR') { + id2 = 7 + } else if (id === 'knobCR') { + id2 = 15 + } else if (id === 'knobBR') { + id2 = 23 + } + + if (id2 !== undefined) { + switch (delta) { + case -1: + client.keyUp(this.deviceId, id2) + break + case 1: + client.keyDown(this.deviceId, id2) + break + } + } + }) + const translateKeyIndex = (key: number): number => { + const x = key % 4 + const y = Math.floor(key / 4) + return y * 8 + x + 2 + } + this.#deck.on('touchstart', (data) => { + for (const touch of data.changedTouches) { + if (touch.target.key !== undefined) { + client.keyDown(this.deviceId, translateKeyIndex(touch.target.key)) + } + } + }) + this.#deck.on('touchend', (data) => { + for (const touch of data.changedTouches) { + if (touch.target.key !== undefined) { + client.keyUp(this.deviceId, translateKeyIndex(touch.target.key)) + } + } + }) + + // Start with blanking it + await this.blankDevice() + + await this.showStatus(client.host, status) + } + + async deviceAdded(): Promise { + this.#queueOutputId++ + } + async setBrightness(percent: number): Promise { + this.#deck.setBrightness(percent / 100) + } + async blankDevice(): Promise { + for (let i = 0; i < 8; i++) { + this.#deck.setButtonColor({ + id: i === 0 ? 'circle' : ((i + '') as any), + color: '#0000', + }) + } + + // await this.#deck.clearPanel() + } + async draw(d: DeviceDrawProps): Promise { + if (d.keyIndex >= 24 && d.keyIndex < 32) { + const index = d.keyIndex - 24 + const color = d.color || '#000000' + this.#deck.setButtonColor({ + id: index === 0 ? 'circle' : ((index + '') as any), + color, + }) + return + } + const x = (d.keyIndex % 8) - 2 + const y = Math.floor(d.keyIndex / 8) + + if (x >= 0 && x < 4) { + const keyIndex = x + y * 4 + if (d.image) { + this.#queue.queue(keyIndex, d.image) + } else { + throw new Error(`Cannot draw for Loupedeck without image`) + } + } + } + async showStatus(hostname: string, status: string): Promise { + // abort and discard current operations + this.#queue?.abort() + this.#queueOutputId++ + const outputId = this.#queueOutputId + this.#cardGenerator + .generateBasicCard(360, 270, hostname, status) + .then(async (buffer) => { + if (outputId === this.#queueOutputId) { + // still valid + this.#deck.drawBuffer({ + id: 'center', + buffer, + }) + } + }) + .catch((e) => { + console.error(`Failed to fill device`, e) + }) + } +} diff --git a/src/device-types/streamdeck.ts b/src/device-types/streamdeck.ts index 0e4b54b..0cef81c 100644 --- a/src/device-types/streamdeck.ts +++ b/src/device-types/streamdeck.ts @@ -114,7 +114,7 @@ export class StreamDeckWrapper implements WrappedDevice { .then(async (buffer) => { if (outputId === this.#queueOutputId) { // still valid - await this.#deck.fillPanelBuffer(buffer, { format: 'rgba' }) + await this.#deck.fillPanelBuffer(buffer) } }) .catch((e) => { diff --git a/src/devices.ts b/src/devices.ts index 7c660c5..87131a9 100644 --- a/src/devices.ts +++ b/src/devices.ts @@ -8,6 +8,8 @@ import { StreamDeckWrapper } from './device-types/streamdeck' import { QuickKeysWrapper } from './device-types/xencelabs-quick-keys' import Infinitton = require('infinitton-idisplay') import { InfinittonWrapper } from './device-types/infinitton' +import { LoupedeckWrapper } from './device-types/loupedeck' +import { listDevices as listLoupedecks, LoupedeckDevice } from 'loupedeck' import * as HID from 'node-hid' // Force into hidraw mode @@ -38,6 +40,8 @@ export class DeviceManager { XencelabsQuickKeysManagerInstance.scanDevices().catch((e) => { console.error(`Quickey scan failed: ${e}`) }) + } else if (dev.deviceDescriptor.idVendor === 0x2ec2) { + this.foundDevice(dev) } }) usb.on('detach', (dev) => { @@ -220,6 +224,41 @@ export class DeviceManager { XencelabsQuickKeysManagerInstance.openDevicesFromArray(devices).catch((e) => { console.error(`Quick keys scan failed: ${e}`) }) + + listLoupedecks({ ignoreWebsocket: true }) + .then((devs) => { + for (const dev of devs) { + if (dev.type === 'serial' && dev.serialNumber) { + this.tryAddLoupedeck(dev.path, dev.serialNumber) + } + } + }) + .catch((e) => { + console.error(`Loupedeck scan failed: ${e}`) + }) + } + + private async tryAddLoupedeck(path: string, serial: string) { + let ld: LoupedeckDevice | undefined + try { + if (!this.devices.has(serial)) { + console.log(`adding new device: ${path}`) + console.log(`existing = ${JSON.stringify(Array.from(this.devices.keys()))}`) + + ld = new LoupedeckDevice({ path, autoConnect: false }) + // ld.on('error', (e) => { + // console.error('device error', e) + // this.cleanupDeviceById(serial) + // }) + await ld.connect() + + const devInfo = new LoupedeckWrapper(serial, ld, this.cardGenerator) + await this.tryAddDeviceInner(serial, devInfo) + } + } catch (e) { + console.log(`Open "${path}" failed: ${e}`) + if (ld) ld.close() //.catch((e) => null) + } } private async tryAddStreamdeck(path: string, serial: string) { diff --git a/yarn.lock b/yarn.lock index a949ae2..47ca961 100644 --- a/yarn.lock +++ b/yarn.lock @@ -164,6 +164,95 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@serialport/binding-mock@10.2.2": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@serialport/binding-mock/-/binding-mock-10.2.2.tgz#d322a8116a97806addda13c62f50e73d16125874" + integrity sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw== + dependencies: + "@serialport/bindings-interface" "^1.2.1" + debug "^4.3.3" + +"@serialport/bindings-cpp@10.7.0": + version "10.7.0" + resolved "https://registry.yarnpkg.com/@serialport/bindings-cpp/-/bindings-cpp-10.7.0.tgz#9cf7dda78d914ba597933089abe2a8511e875851" + integrity sha512-Xx1wA2UCG2loS32hxNvWJI4smCzGKhWqE85//fLRzHoGgE1lSLe3Nk7W40/ebrlGFHWRbQZmeaIF4chb2XLliA== + dependencies: + "@serialport/bindings-interface" "1.2.1" + "@serialport/parser-readline" "^10.2.1" + debug "^4.3.2" + node-addon-api "^4.3.0" + node-gyp-build "^4.3.0" + +"@serialport/bindings-interface@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@serialport/bindings-interface/-/bindings-interface-1.2.1.tgz#1ee80b0951ef4e4fd8a5a186621feff046aa2faf" + integrity sha512-63Dyqz2gtryRDDckFusOYqLYhR3Hq/M4sEdbF9i/VsvDb6T+tNVgoAKUZ+FMrXXKnCSu+hYbk+MTc0XQANszxw== + +"@serialport/bindings-interface@^1.2.1": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz#c4ae9c1c85e26b02293f62f37435478d90baa460" + integrity sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA== + +"@serialport/parser-byte-length@10.3.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@serialport/parser-byte-length/-/parser-byte-length-10.3.0.tgz#c650b8883f716af77196e8466e86861b55290201" + integrity sha512-pJ/VoFemzKRRNDHLhFfPThwP40QrGaEnm9TtwL7o2GihEPwzBg3T0bN13ew5TpbbUYZdMpUtpm3CGfl6av9rUQ== + +"@serialport/parser-cctalk@10.3.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@serialport/parser-cctalk/-/parser-cctalk-10.3.0.tgz#d8fc7ab480910b28362b1ff154c01c170ac201a3" + integrity sha512-8ujmk8EvVbDPrNF4mM33bWvUYJOZ0wXbY3WCRazHRWvyCdL0VO0DQvW81ZqgoTpiDQZm5r8wQu9rmuemahF6vQ== + +"@serialport/parser-delimiter@10.3.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@serialport/parser-delimiter/-/parser-delimiter-10.3.0.tgz#4bcbbeed7e3c6fed2c116535f0754bbf8a33f015" + integrity sha512-9E4Vj6s0UbbcCCTclwegHGPYjJhdm9qLCS0lowXQDEQC5naZnbsELemMHs93nD9jHPcyx1B4oXkMnVZLxX5TYw== + +"@serialport/parser-inter-byte-timeout@10.3.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-10.3.0.tgz#57e4fceeeeb13415dcded3ef1ba5d28a9fa0c611" + integrity sha512-wKP0QK85NHgvT6BBB1qBfKBBU4pf8kespNXAZBUYmFT+P4n8r8IZE2mqigCD+AiZcfWNQoAizwOsT/Jx/qeVig== + +"@serialport/parser-packet-length@10.3.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@serialport/parser-packet-length/-/parser-packet-length-10.3.0.tgz#9faefc91841c011925a6d4fa4fad6ed44bf11ed6" + integrity sha512-bj0cWzt8YSQj/E5fRQVYdi4TsfTlZQrXlXrUwjyTsCONv8IPOHzsz+yY0fw5SEMiJtaLyqvPkCHLsttOd/zFsg== + +"@serialport/parser-readline@10.3.0", "@serialport/parser-readline@^10.2.1": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@serialport/parser-readline/-/parser-readline-10.3.0.tgz#7db9143ce9a2537a4086b3824a70fe53ae2107b3" + integrity sha512-ki3ATZ3/RAqnqGROBKE7k+OeZ0DZXZ53GTca4q71OU5RazbbNhTOBQLKLXD3v9QZXCMJdg4hGW/2Y0DuMUqMQg== + dependencies: + "@serialport/parser-delimiter" "10.3.0" + +"@serialport/parser-ready@10.3.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@serialport/parser-ready/-/parser-ready-10.3.0.tgz#6a93d8970dc827e57829a9ee54802469be71f185" + integrity sha512-1owywJ4p592dJyVrEJZPIh6pUZ3/y/LN6kGTDH2wxdewRUITo/sGvDy0er5i2+dJD3yuowiAz0dOHSdz8tevJA== + +"@serialport/parser-regex@10.3.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@serialport/parser-regex/-/parser-regex-10.3.0.tgz#7c7314036535414bb7ada3520598d233f5ea09d0" + integrity sha512-tIogTs7CvTH+UUFnsvE7i33MSISyTPTGPWlglWYH2/5coipXY503jlaYS1YGe818wWNcSx6YAjMZRdhTWwM39w== + +"@serialport/parser-slip-encoder@10.3.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@serialport/parser-slip-encoder/-/parser-slip-encoder-10.3.0.tgz#ff01cd7cda2258ad2ca3ebb113145a0aa7272c78" + integrity sha512-JI0ILF5sylWn8f0MuMzHFBix/iMUTa79/Z95KaPZYnVaEdA7h7hh/o21Jmon/26P3RJwL1SNJCjZ81zfan+LtQ== + +"@serialport/parser-spacepacket@10.3.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@serialport/parser-spacepacket/-/parser-spacepacket-10.3.0.tgz#447d49b0690527ea770410ffe0a2ade2079ae7b2" + integrity sha512-PDF73ClEPsClD1FEJZHNuBevDKsJCkqy/XD5+S5eA6+tY5D4HLrVgSWsg+3qqB6+dlpwf2CzHe+uO8D3teuKHA== + +"@serialport/stream@10.3.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@serialport/stream/-/stream-10.3.0.tgz#fdb13ed4487379615819203217060b114548c0a6" + integrity sha512-7sooi5fHogYNVEJwxVdg872xO6TuMgQd2E9iRmv+o8pk/1dbBnPkmH6Ka3st1mVE+0KnIJqVlgei+ncSsqXIGw== + dependencies: + "@serialport/bindings-interface" "1.2.1" + debug "^4.3.2" + "@sindresorhus/is@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" @@ -1038,6 +1127,26 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-parse@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/color-parse/-/color-parse-1.4.2.tgz#78651f5d34df1a57f997643d86f7f87268ad4eb5" + integrity sha512-RI7s49/8yqDj3fECFZjUI1Yi0z/Gq1py43oNJivAIIDSyJiOZLfYCRQEgn8HEVAj++PcRe8AnL2XF0fRJ3BTnA== + dependencies: + color-name "^1.0.0" + +color-rgba@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/color-rgba/-/color-rgba-2.4.0.tgz#ae85819c530262c29fc2da129fc7c8f9efc57015" + integrity sha512-Nti4qbzr/z2LbUWySr7H9dk3Rl7gZt7ihHAxlgT4Ho90EXWkjtkL1avTleu9yeGuqrt/chxTB6GKK8nZZ6V0+Q== + dependencies: + color-parse "^1.4.2" + color-space "^2.0.0" + +color-space@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/color-space/-/color-space-2.0.0.tgz#ae7813abcbe3dabda9e3e2266b0675f688b24977" + integrity sha512-Bu8P/usGNuVWushjxcuaGSkhT+L2KX0cvgMGMTF0KJ7lFeqonhsntT68d6Yu3uwZzCmbF7KTB9EV67AGcUXhJw== + color-string@^1.9.0: version "1.9.1" resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" @@ -1400,7 +1509,7 @@ debounce-fn@^4.0.0: dependencies: mimic-fn "^3.0.0" -debug@4, debug@^4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: +debug@4, debug@^4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -3034,6 +3143,14 @@ log-update@^4.0.0: slice-ansi "^4.0.0" wrap-ansi "^6.2.0" +"loupedeck@github:julusian/loupedeck#master": + version "1.3.3-julusian.0" + resolved "https://codeload.github.com/julusian/loupedeck/tar.gz/d5ec7c1951b8767c80193bc5e387aad1e0eb0733" + dependencies: + color-rgba "^2.4.0" + serialport "^10.4.0" + ws "^8.6.0" + lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" @@ -3336,7 +3453,7 @@ node-addon-api@^3.2.1: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== -node-addon-api@^4.2.0: +node-addon-api@^4.2.0, node-addon-api@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== @@ -4196,6 +4313,26 @@ serialize-error@^7.0.1: dependencies: type-fest "^0.13.1" +serialport@^10.4.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/serialport/-/serialport-10.4.0.tgz#93c95ecccc0e314d5bbee3f06cde2c70be70e671" + integrity sha512-PszPM5SnFMgSXom60PkKS2A9nMlNbHkuoyRBlzdSWw9rmgOn258+V0dYbWMrETJMM+TJV32vqBzjg5MmmUMwMw== + dependencies: + "@serialport/binding-mock" "10.2.2" + "@serialport/bindings-cpp" "10.7.0" + "@serialport/parser-byte-length" "10.3.0" + "@serialport/parser-cctalk" "10.3.0" + "@serialport/parser-delimiter" "10.3.0" + "@serialport/parser-inter-byte-timeout" "10.3.0" + "@serialport/parser-packet-length" "10.3.0" + "@serialport/parser-readline" "10.3.0" + "@serialport/parser-ready" "10.3.0" + "@serialport/parser-regex" "10.3.0" + "@serialport/parser-slip-encoder" "10.3.0" + "@serialport/parser-spacepacket" "10.3.0" + "@serialport/stream" "10.3.0" + debug "^4.3.3" + set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -4974,6 +5111,11 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" +ws@^8.6.0: + version "8.8.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.8.1.tgz#5dbad0feb7ade8ecc99b830c1d77c913d4955ff0" + integrity sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA== + xdg-basedir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"