diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7d1f046 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# Generated by Home Manager +root=true + +[*] +charset=utf-8 +end_of_line=lf +indent_size=4 +indent_style=space +insert_final_newline=true +trim_trailing_whitespace=true + diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..88c47f8 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,55 @@ +# +name: Create and publish a Docker image + +# Configures this workflow to run every time a change is pushed to the branch called `release`. +on: + push: + branches: ["master", "main"] + +# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. +jobs: + build-and-push-image: + runs-on: ubuntu-latest + strategy: + matrix: + platform: [linux/amd64, linux/arm64, linux/arm] + + # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. + permissions: + contents: read + packages: write + # + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. + # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. + # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. + - name: Build and push Docker image + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.reg/settings b/.reg/settings new file mode 100644 index 0000000..d012cbe --- /dev/null +++ b/.reg/settings @@ -0,0 +1,30 @@ +# Set the AUTHOR variable to the name you want to be presented with to the user +AUTHOR="doc bobo" + +# Set the extension ID to a string that uniquely identifies your extension, e.g. based on an internet domain you own +EXTENSION_ID=org.pruessmann.roon-extension-denon + +# Set the NAME variable to the name of your extension +NAME=roon-extension-denon + +# Set the FRIENDLY_NAME variable to a more readable name for your extension +FRIENDLY_NAME="Denon/Marantz AVR" + +# Set the DESCRIPTION variable to a single line string that summarizes the function of your extension +DESCRIPTION="Roon Volume Control Extension for Denon/Marantz AVR receivers" + +# Set the VERSION variable to the version number of your extension +VERSION=2025.1.1 + +# Set the EMAIL variable to the address that users can use for support questions +EMAIL=docbobo@pm.me + +# Set the WEBSITE variable to the location where more information about your extension can be found +# This can be your website, a Roon Community forum post, the GitHub respository of your extension, etc. +WEBSITE=https://github.com/docbobo/roon-extension-denon + +# Set the MAIN_FILE variable to the main source file of your extension +MAIN_FILE=app.js + +# Set the USER variable to your Docker Hub account name +USER=docbobo diff --git a/Dockerfile b/Dockerfile index ed1ddcc..0694b72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:23 +FROM node:23-alpine3.21 # Create app directory WORKDIR /usr/src/app @@ -15,4 +15,4 @@ RUN npm install # Bundle app source COPY . . -CMD [ "node", "app.js" ] \ No newline at end of file +CMD [ "node", "app.js" ] diff --git a/app.js b/app.js index 0c6690d..462fa7d 100644 --- a/app.js +++ b/app.js @@ -1,24 +1,24 @@ "use strict"; -var debug = require('debug')('roon-extension-denon'), - debug_keepalive = require('debug')('roon-extension-denon:keepalive'), - Denon = require('denon-client'), - RoonApi = require('node-roon-api'), - RoonApiSettings = require('node-roon-api-settings'), - RoonApiStatus = require('node-roon-api-status'), - RoonApiVolumeControl = require('node-roon-api-volume-control'), - RoonApiSourceControl = require('node-roon-api-source-control'), - fetch = require('node-fetch'), - parse = require('fast-xml-parser'); +var debug = require("debug")("roon-extension-denon"), + debug_keepalive = require("debug")("roon-extension-denon:keepalive"), + Denon = require("denon-client"), + RoonApi = require("node-roon-api"), + RoonApiSettings = require("node-roon-api-settings"), + RoonApiStatus = require("node-roon-api-status"), + RoonApiVolumeControl = require("node-roon-api-volume-control"), + RoonApiSourceControl = require("node-roon-api-source-control"), + fetch = require("node-fetch"), + parse = require("fast-xml-parser"); var denon = {}; var roon = new RoonApi({ - extension_id: 'org.pruessmann.roon.denon', - display_name: 'Denon/Marantz AVR', - display_version: '0.0.14', - publisher: 'Doc Bobo', - email: 'boris@pruessmann.org', - website: 'https://github.com/docbobo/roon-extension-denon', + extension_id: "org.pruessmann.roon.denon", + display_name: "Denon/Marantz AVR", + display_version: "2025.1.1", + publisher: "Doc Bobo", + email: "docbobo@pm.me", + website: "https://github.com/docbobo/roon-extension-denon", }); var mysettings = roon.load_config("settings") || { @@ -30,7 +30,7 @@ function make_layout(settings) { var l = { values: settings, layout: [], - has_error: false + has_error: false, }; l.layout.push({ @@ -60,31 +60,34 @@ function make_layout(settings) { return l; } - var svc_settings = new RoonApiSettings(roon, { - get_settings: function(cb) { - probeInputs(mysettings) - .then((settings) => { - cb(make_layout(settings)); - }); + get_settings: function (cb) { + probeInputs(mysettings).then((settings) => { + cb(make_layout(settings)); + }); }, - save_settings: function(req, isdryrun, settings) { - probeInputs(settings.values) - .then((settings) => { - let l = make_layout(settings); - req.send_complete(l.has_error ? "NotValid" : "Success", { settings: l }); - delete settings.inputs; - - if (!l.has_error && !isdryrun) { - var old_hostname = mysettings.hostname; - var old_setsource = mysettings.setsource; - mysettings = l.values; - svc_settings.update_settings(l); - if (old_hostname != mysettings.hostname || old_setsource != mysettings.setsource) setup_denon_connection(mysettings.hostname); - roon.save_config("settings", mysettings); - } + save_settings: function (req, isdryrun, settings) { + probeInputs(settings.values).then((settings) => { + let l = make_layout(settings); + req.send_complete(l.has_error ? "NotValid" : "Success", { + settings: l, }); - } + delete settings.inputs; + + if (!l.has_error && !isdryrun) { + var old_hostname = mysettings.hostname; + var old_setsource = mysettings.setsource; + mysettings = l.values; + svc_settings.update_settings(l); + if ( + old_hostname != mysettings.hostname || + old_setsource != mysettings.setsource + ) + setup_denon_connection(mysettings.hostname); + roon.save_config("settings", mysettings); + } + }); + }, }); var svc_status = new RoonApiStatus(roon); @@ -92,19 +95,25 @@ var svc_volume_control = new RoonApiVolumeControl(roon); var svc_source_control = new RoonApiSourceControl(roon); roon.init_services({ - provided_services: [svc_status, svc_settings, svc_volume_control, svc_source_control] + provided_services: [ + svc_status, + svc_settings, + svc_volume_control, + svc_source_control, + ], }); function probeInputs(settings) { - - let inputs = (settings.hostname ? - queryInputs(settings.hostname) - .then(inputs => { - delete settings.err; - settings.inputs = inputs - }) : Promise.resolve()) - - .catch(err => { + let inputs = ( + settings.hostname + ? queryInputs(settings.hostname).then((inputs) => { + delete settings.err; + settings.inputs = inputs; + }) + : Promise.resolve() + ) + + .catch((err) => { settings.err = err.message; }) .then(() => { @@ -116,25 +125,25 @@ function probeInputs(settings) { function queryInputs(hostname) { return Promise.resolve( Object.keys(Denon.Options.InputOptions) - .filter(title => title != 'Status') - .sort() - .map(title => { - return { title, value: Denon.Options.InputOptions[title] } - }) + .filter((title) => title != "Status") + .sort() + .map((title) => { + return { title, value: Denon.Options.InputOptions[title] }; + }), ); } function setup_denon_connection(host) { debug("setup_denon_connection (" + host + ")"); - if (denon.keepalive) {  + if (denon.keepalive) { clearInterval(denon.keepalive); denon.keepalive = null; } if (denon.client) { - denon.client.removeAllListeners('close'); + denon.client.removeAllListeners("close"); denon.client.disconnect(); - delete(denon.client); + delete denon.client; } if (!host) { @@ -147,41 +156,50 @@ function setup_denon_connection(host) { denon.client.socket.setTimeout(0); denon.client.socket.setKeepAlive(true, 10000); - denon.client.socket.on('error', (error) => { + denon.client.socket.on("error", (error) => { // Handler for debugging purposes. No need to reconnect since the event will be followed by a close event, // according to documentation. - debug('Received onError(%O)', error); + debug("Received onError(%O)", error); }); - denon.client.on('data', (data) => { + denon.client.on("data", (data) => { debug("%s", data); }); - denon.client.socket.on('timeout', () => { - debug('Received onTimeout(): Closing connection...'); + denon.client.socket.on("timeout", () => { + debug("Received onTimeout(): Closing connection..."); denon.client.disconnect(); }); - denon.client.on('close', (had_error) => { - debug('Received onClose(%O): Reconnecting...', had_error); + denon.client.on("close", (had_error) => { + debug("Received onClose(%O): Reconnecting...", had_error); if (denon.client) { - svc_status.set_status("Connection closed by receiver. Reconnecting...", true); + svc_status.set_status( + "Connection closed by receiver. Reconnecting...", + true, + ); setTimeout(() => { connect(); }, 1000); } else { - svc_status.set_status("Not configured, please check settings.", true); + svc_status.set_status( + "Not configured, please check settings.", + true, + ); } }); - denon.client.on('powerChanged', (val) => { + denon.client.on("powerChanged", (val) => { debug("powerChanged: val=%s", val); let old_power_value = denon.source_state.Power; denon.source_state.Power = val; if (old_power_value != denon.source_state.Power) { - let stat = check_status(denon.source_state.Power, denon.source_state.Input); + let stat = check_status( + denon.source_state.Power, + denon.source_state.Input, + ); debug("Power differs - updating"); if (denon.source_control) { denon.source_control.update_state({ status: stat }); @@ -189,45 +207,53 @@ function setup_denon_connection(host) { } }); - denon.client.on('inputChanged', (val) => { + denon.client.on("inputChanged", (val) => { debug("inputChanged: val=%s", val); let old_Input = denon.source_state.Input; denon.source_state.Input = val; if (old_Input != denon.source_state.Input) { - let stat = check_status(denon.source_state.Power, denon.source_state.Input); + let stat = check_status( + denon.source_state.Power, + denon.source_state.Input, + ); debug("input differs - updating"); if (denon.source_control) { denon.source_control.update_state({ status: stat }); } - } }); - denon.client.on('muteChanged', (val) => { + denon.client.on("muteChanged", (val) => { debug("muteChanged: val=%s", val); denon.volume_state.is_muted = val === Denon.Options.MuteOptions.On; if (denon.volume_control) { - denon.volume_control.update_state({ is_muted: denon.volume_state.is_muted }); + denon.volume_control.update_state({ + is_muted: denon.volume_state.is_muted, + }); } }); - denon.client.on('masterVolumeChanged', (val) => { + denon.client.on("masterVolumeChanged", (val) => { debug("masterVolumeChanged: val=%s", val - 80); denon.volume_state.volume_value = val - 80; if (denon.volume_control) { - denon.volume_control.update_state({ volume_value: denon.volume_state.volume_value }); + denon.volume_control.update_state({ + volume_value: denon.volume_state.volume_value, + }); } }); - denon.client.on('masterVolumeMaxChanged', (val) => { + denon.client.on("masterVolumeMaxChanged", (val) => { debug("masterVolumeMaxChanged: val=%s", val - 80); denon.volume_state.volume_max = val - 80; if (denon.volume_control) { - denon.volume_control.update_state({ volume_max: denon.volume_state.volume_max }); + denon.volume_control.update_state({ + volume_max: denon.volume_state.volume_max, + }); } }); @@ -243,10 +269,14 @@ function setup_denon_connection(host) { } function connect() { - - denon.client.connect() + denon.client + .connect() .then(() => create_volume_control(denon)) - .then(() => mysettings.setsource ? create_source_control(denon) : Promise.resolve()) + .then(() => + mysettings.setsource + ? create_source_control(denon) + : Promise.resolve(), + ) .then(() => { svc_status.set_status("Connected to receiver", false); }) @@ -260,7 +290,6 @@ function connect() { } function check_status(power, input) { - let stat = ""; if (power == "ON") { if (input == mysettings.setsource) { @@ -276,7 +305,7 @@ function check_status(power, input) { } function create_volume_control(denon) { - debug("create_volume_control: volume_control=%o", denon.volume_control) + debug("create_volume_control: volume_control=%o", denon.volume_control); if (!denon.volume_control) { denon.volume_state = { display_name: "Main Zone", @@ -292,115 +321,143 @@ function create_volume_control(denon) { set_volume: function(req, mode, value) { debug("set_volume: mode=%s value=%d", mode, value); - let newvol = mode == "absolute" ? value : (state.volume_value + value); - if (newvol < this.state.volume_min) newvol = this.state.volume_min; - else if (newvol > this.state.volume_max) newvol = this.state.volume_max; + let newvol = + mode == "absolute" ? value : state.volume_value + value; + if (newvol < this.state.volume_min) + newvol = this.state.volume_min; + else if (newvol > this.state.volume_max) + newvol = this.state.volume_max; - denon.client.setVolume(newvol + 80).then(() => { - debug("set_volume: Succeeded."); - req.send_complete("Success"); - }).catch((error) => { - debug("set_volume: Failed with error."); + denon.client + .setVolume(newvol + 80) + .then(() => { + debug("set_volume: Succeeded."); + req.send_complete("Success"); + }) + .catch((error) => { + debug("set_volume: Failed with error."); - console.log(error); - req.send_complete("Failed"); - }); + console.log(error); + req.send_complete("Failed"); + }); }, set_mute: function(req, inAction) { debug("set_mute: action=%s", inAction); const action = !this.state.is_muted ? "on" : "off"; - denon.client.setMute(action === "on" ? Denon.Options.MuteOptions.On : Denon.Options.MuteOptions.Off) + denon.client + .setMute( + action === "on" + ? Denon.Options.MuteOptions.On + : Denon.Options.MuteOptions.Off, + ) .then(() => { debug("set_mute: Succeeded."); req.send_complete("Success"); - }).catch((error) => { + }) + .catch((error) => { debug("set_mute: Failed."); console.log(error); req.send_complete("Failed"); }); - } + }, }; } - let result = denon.client.getVolume().then((val) => { - denon.volume_state.volume_value = val - 80; - return denon.client.getMaxVolume(); - }).then((val) => { - denon.volume_state.volume_max = val - 80; - return denon.client.getMute(); - }).then((val) => { - denon.volume_state.is_muted = (val === Denon.Options.MuteOptions.On); - if (denon.volume_control) { - denon.volume_control.update_state(denon.volume_state); - } else { - debug("Registering volume control extension"); - denon.volume_control = svc_volume_control.new_device(device); - } - }); + let result = denon.client + .getVolume() + .then((val) => { + denon.volume_state.volume_value = val - 80; + return denon.client.getMaxVolume(); + }) + .then((val) => { + denon.volume_state.volume_max = val - 80; + return denon.client.getMute(); + }) + .then((val) => { + denon.volume_state.is_muted = val === Denon.Options.MuteOptions.On; + if (denon.volume_control) { + denon.volume_control.update_state(denon.volume_state); + } else { + debug("Registering volume control extension"); + denon.volume_control = svc_volume_control.new_device(device); + } + }); return result; } function create_source_control(denon) { - debug("create_source_control: source_control=%o", denon.source_control) + debug("create_source_control: source_control=%o", denon.source_control); if (!denon.source_control) { denon.source_state = { display_name: "Main Zone", supports_standby: true, status: "", Power: "", - Input: "" + Input: "", }; var device = { state: denon.source_state, control_key: 2, - convenience_switch: function(req) { + convenience_switch: function (req) { if (denon.source_state.Power === "STANDBY") { - denon.client.setPower('ON'); + denon.client.setPower("ON"); } if (denon.source_state.Input == mysettings.setsource) { req.send_complete("Success"); } else { - denon.client.setInput(mysettings.setsource).then(() => { - req.send_complete("Success"); - }).catch((error) => { - debug("set_source: Failed with error."); - req.send_complete("Failed"); - }); + denon.client + .setInput(mysettings.setsource) + .then(() => { + req.send_complete("Success"); + }) + .catch((error) => { + debug("set_source: Failed with error."); + req.send_complete("Failed"); + }); } }, standby: function(req) { denon.client.getPower().then((val) => { - denon.client.setPower(val === 'STANDBY' ? "ON" : "STANDBY").then(() => { - req.send_complete("Success"); - }).catch((error) => { - debug("set_standby: Failed with error."); - - console.log(error); - req.send_complete("Failed"); - }); + denon.client + .setPower(val === "STANDBY" ? "ON" : "STANDBY") + .then(() => { + req.send_complete("Success"); + }) + .catch((error) => { + debug("set_standby: Failed with error."); + + console.log(error); + req.send_complete("Failed"); + }); }); - } + }, }; } - let result = denon.client.getPower().then((val) => { - denon.source_state.Power = val; - return denon.client.getInput(); - }).then((val) => { - denon.source_state.Input = val; - denon.source_state.status = check_status(denon.source_state.Power, denon.source_state.Input); - if (denon.source_control) { - denon.source_control.update_state(denon.source_state); - } else { - debug("Registering source control extension"); - denon.source_control = svc_source_control.new_device(device); - } - }); + let result = denon.client + .getPower() + .then((val) => { + denon.source_state.Power = val; + return denon.client.getInput(); + }) + .then((val) => { + denon.source_state.Input = val; + denon.source_state.status = check_status( + denon.source_state.Power, + denon.source_state.Input, + ); + if (denon.source_control) { + denon.source_control.update_state(denon.source_state); + } else { + debug("Registering source control extension"); + denon.source_control = svc_source_control.new_device(device); + } + }); return result; } diff --git a/package.json b/package.json index cf7cf94..5f7114c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roon-extension-denon", - "version": "0.0.14", + "version": "2025.1.1", "description": "Volume Control extension to control Denon/Marantz AV receivers via network.", "main": "app.js", "author": "Doc Bobo (https://blog.pruessmann.org/)",