isundil 6 年之前
父節點
當前提交
3b38713e07
共有 8 個文件被更改,包括 359 次插入0 次删除
  1. 80 0
      alsaOutput.js
  2. 22 0
      config.js
  3. 65 0
      main.js
  4. 27 0
      outputs.js
  5. 4 0
      public/index.html
  6. 59 0
      public/script.js
  7. 22 0
      public/style.css
  8. 80 0
      remoteOutput.js

+ 80 - 0
alsaOutput.js

@@ -0,0 +1,80 @@
+
+const SSH_CMD = "/usr/bin/ssh amixer@knacki.info"
+
+function sshCmd(cmd) {
+	var cmdString = (SSH_CMD +' ' +cmd),
+		cmd = cmdString.split(' ');
+	return require('child_process').spawnSync(cmd[0], cmd.splice(1)).stdout.toString("utf-8");
+}
+
+function sshSetVolume(controlName, status) {
+	sshCmd("mixSet " +status +" " +controlName);
+}
+
+function isChannelEnabled(line) {
+	var percent = (/\[([0-9]+)%\]/).exec(line),
+		state = (/\'([A-Za-z]+)\'/).exec(line);
+	if (percent)
+		return Number.parseInt(percent[1]) > 0;
+	if (state)
+		return state[1] === "Enabled";
+	return false;
+}
+
+function sshGetVolume(controlName, channelList) {
+	var output = sshCmd("mixGet " +controlName).split("\n");
+	for (var i =0, len =output.length; i < len; ++i)
+		for (var j =0, channelLen =channelList.length; j < channelLen; ++j) {
+			var pos = output[i].indexOf(channelList[j] +":");
+			if (pos >= 0 && isChannelEnabled(output[i].substr(pos +channelList[j].length +1)))
+				return true;
+		}
+	return false;
+}
+
+function GenerateVolume(controlName) {
+	return {
+		on: function() { sshSetVolume(controlName, "100%") },
+		off: function() { sshSetVolume(controlName, "0%") },
+		get: function() { return sshGetVolume(controlName, [ "Front Left", "Front Right" ]); }
+	};
+}
+
+function GenerateToggle(controlName) {
+	return {
+		on: function() { sshSetVolume(controlName, "Enabled") },
+		off: function() { sshSetVolume(controlName, "Disabled") },
+		get: function() { return sshGetVolume(controlName, [ "Item0" ]); }
+	};
+}
+
+const inputs = {};
+
+(function() {
+	var inputConfig = require('./config.js').ALSA_INPUT;
+	for (var i in inputConfig.volume)
+		inputs[i] = GenerateVolume(inputConfig.volume[i]);
+	for (var i in inputConfig.toggle)
+		inputs[i] = GenerateToggle(inputConfig.toggle[i]);
+})();
+
+module.exports.init = function() {
+	require("./outputs.js").registerOutput("alsa", {
+		name: "PC Speaker",
+		getInputs: function() {
+			var speakerInputs = {};
+			for (var i in inputs)
+				speakerInputs[i] = inputs[i].get();
+			return speakerInputs;
+		},
+		setState: function(inputId, state) {
+			var input = inputs[inputId];
+			if (input === undefined)
+				return false;
+			state ? input.on() : input.off();
+			return true;
+		},
+		volumeControl: false
+	});
+}
+

+ 22 - 0
config.js

@@ -0,0 +1,22 @@
+
+module.exports = {
+	HTTP_PORT: 9000,
+	TCP_PORT: 9001,
+	TCP_LISTEN: "192.168.0.5",
+	RADIOS: {
+		Arch: "http://192.168.0.5:80/arch.ogg",
+		Windows10: "http://192.168.0.5:80/win.ogg",
+		HDMI: "http://192.168.0.5:80/hdmi.ogg",
+		Spotify: "http://192.168.0.5:80/spotify.ogg"
+	},
+	ALSA_INPUT: {
+		volume: {
+			"Arch": "ArchVM",
+			"Windows10": "WinVM",
+			"Mediaki": "MediaVM"
+		}, toggle: {
+			"HDMI": "Loopback Mixing"
+		}
+	}
+};
+

+ 65 - 0
main.js

@@ -0,0 +1,65 @@
+
+const outputApi = require("./outputs.js");
+
+require('process').chdir(__dirname);
+
+function notFound(res) {
+	res.statusCode = 404;
+	res.statusMessage = "Not Found";
+	res.end(res.statusMessage);
+}
+
+function serveFile(url, res) {
+	require("fs").readFile("./public/" +(url || "index.html").replace('/\//g', ''), (err, data) => {
+		if (err)
+			notFound(res);
+		else
+			res.end(data);
+	});
+}
+
+function serveApi(method, url, res) {
+	var urlParts = url.split('?', 2),
+		args = (urlParts[1] || "").split('&'),
+		argObj = {};
+	url = urlParts[0];
+	args.forEach(i => {
+		var argSplited = i.split("=", 2);
+		argObj[argSplited[0]] = argSplited[1] || true;
+	});
+	switch (url) {
+		case "outputs":
+			res.end(JSON.stringify(outputApi.listOutputs()));
+			break;
+		case "setState":
+			if (argObj.output === undefined || argObj.input === undefined || argObj.state === undefined) {
+				notFound(res);
+			} else {
+				outputApi.setOutputState(argObj.output, argObj.input, argObj.state == 1);
+				res.end(JSON.stringify(outputApi.listOutputs()));
+			}
+			break;
+		default:
+			notFound(res);
+			break;
+	}
+}
+
+function onRequest(req, res) {
+	const url = req.url.split("/").filter(i => i.length);
+	if (req.method == "GET" && url[0] !== "api")
+		serveFile(url[0], res);
+	else if (url[0] === "api")
+		serveApi(req.method, url.splice(1).join("/"), res);
+	else
+		notFound(res);
+}
+
+const srv = require('http').createServer(onRequest);
+
+srv.on("clientError", (err, sock) => sock.end("HTTP/1.1 400 Bad Request\r\n\r\n"));
+srv.listen(require('./config.js').HTTP_PORT);
+
+require("./alsaOutput.js").init();
+require("./remoteOutput.js").init();
+

+ 27 - 0
outputs.js

@@ -0,0 +1,27 @@
+
+var outputs = {};
+
+function listOutputs() {
+	var result = [];
+	for (var id in outputs) {
+		var i = outputs[id];
+		result.push({
+			name: i.name || i.getName(),
+			id: id,
+			inputs: i.inputs || i.getInputs(),
+			volumeControl: i.volumeControl
+		});
+	}
+	return result;
+}
+
+function setOutputState(outputId, inputId, state) {
+	var output = outputs[outputId];
+	return output ? output.setState(inputId, state) : false;
+}
+
+module.exports.listOutputs = listOutputs;
+module.exports.setOutputState = setOutputState;
+module.exports.registerOutput = function(id, output) { console.log("Registered new client " +id); outputs[id] = output; }
+module.exports.unregisterOutput = function(id) { console.log("Unregistered client " +id); delete outputs[id]; }
+

+ 4 - 0
public/index.html

@@ -0,0 +1,4 @@
+<!DOCTYPE html5>
+<html><head><title>Mediaki Sound Control Center</title></head><link rel="stylesheet" type="text/css" href="style.css"><body>
+  <div id="outputs"></div>
+<script src="script.js"></script></body></html>

+ 59 - 0
public/script.js

@@ -0,0 +1,59 @@
+(function(){
+	function httpApi(url, method, callback) {
+		var req = new XMLHttpRequest();
+		req.onreadystatechange = function(e) {
+			if (this.readyState === XMLHttpRequest.DONE)
+				callback(this.status, JSON.parse(this.responseText));
+		};
+		req.open(method, url, true);
+		req.send();
+	}
+
+	function setInputState(output, input, state) {
+		httpApi("/api/setState?output=" +encodeURIComponent(output) +"&input=" +encodeURIComponent(input) +"&state=" +(state ? 1 : 0), "POST", (status, outputs) => {
+			if (status !== 200)
+				console.error("Cannot set output list");
+			else
+				displayOutputs(outputs);
+		});
+	}
+
+	function makeOutputNode(data) {
+		var child = document.createElement("fieldset"),
+			inputList = document.createElement("ul");
+		child.classList.add("output");
+		child.innerHTML += "<legend>" +data.name +"</legend>";
+		for (var i in data.inputs) {
+			var inputItem = document.createElement("li");
+			inputItem.classList.add("input");
+			inputItem.classList.add(data.inputs[i] ? "input-enabled" : "input-disabled");
+			inputItem.textContent = i;
+			(function() {
+				var output = data.id,
+					input = i,
+					state = data.inputs[i];
+				inputItem.addEventListener("click", () => setInputState(output, input, !state));
+			})();
+			inputList.appendChild(inputItem);
+		}
+		child.appendChild(inputList);
+		return child;
+	}
+
+	function displayOutputs(outputs) {
+		var div = document.getElementById("outputs");
+		div.innerHTML = "";
+		outputs.forEach(i => div.appendChild(makeOutputNode(i)));
+	}
+
+	function refreshOutput() {
+		httpApi("/api/outputs", "GET", (status, outputs) => {
+			if (status !== 200)
+				console.error("Cannot fetch output list");
+			else
+				displayOutputs(outputs);
+		});
+	}
+
+	refreshOutput();
+})()

+ 22 - 0
public/style.css

@@ -0,0 +1,22 @@
+
+.output ul {
+	list-style: none;
+	padding: 0;
+}
+
+.output .input:before {
+	content: " ";
+	border-radius: 5px;
+	display: inline-block;
+	height: .5em;
+	width: .5em;
+	margin-right: .3em;
+}
+
+.output .input.input-enabled:before {
+	background-color: lime;
+}
+
+.output .input.input-disabled:before {
+	background-color: red;
+}

+ 80 - 0
remoteOutput.js

@@ -0,0 +1,80 @@
+
+const net = require('net');
+var clients = {};
+
+function Client(sock) {
+	var _this = this;
+
+	this.id = sock.remoteAddress;
+	this.name = this.id;
+	this.sock = sock;
+	this.active = true;
+	this.volumeControl = true;
+	this.sock.on('data', (data) => {
+		var dataStr = data.toString("utf-8");
+		dataStr.split(/[\r\n]/).forEach(i => i.length ? _this.onData(i) : i);
+	});
+	this.sock.on('close', () => {
+		if (this.active) {
+			delete clients[this.id];
+			require("./outputs.js").unregisterOutput(this.id);
+		}
+	});
+	this.radios = {};
+
+	var allRadios = require("./config.js").RADIOS;
+	for (var i in allRadios)
+		this.radios[i] = {
+			address: allRadios[i],
+			state: false
+		};
+	this.sendInputStates();
+}
+
+Client.prototype.onData = function(data) {
+	if (data.startsWith("HELO"))
+		this.name = data.replace(/^HELO\s+/, "") +" (" +this.sock.remoteAddress +")";
+}
+
+Client.prototype.getName = function() {
+	return this.name;
+}
+
+Client.prototype.kill = function() {
+	this.sock.destroy();
+	this.active = false;
+}
+
+Client.prototype.getInputs = function() {
+	var result = {};
+	for (var i in this.radios)
+		result[i] = this.radios[i].state;
+	return result;
+}
+
+Client.prototype.sendInputStates = function() {
+	this.sock.write(JSON.stringify(this.radios) +"\n");
+}
+
+Client.prototype.setState = function(inputId, state) {
+	if (!this.radios[inputId])
+		return false;
+	if (this.radios[inputId].state !== !!state) {
+		this.radios[inputId].state = !!state;
+		this.sendInputStates();
+	}
+	return true;
+}
+
+function onClientConnection(sock) {
+	var cli = new Client(sock);
+	if (clients[cli.id])
+		clients[cli.id].kill();
+	clients[cli.id] = cli;
+	require("./outputs.js").registerOutput(cli.id, cli);
+}
+
+module.exports.init = function() {
+	net.createServer(onClientConnection).listen(require('./config.js').TCP_PORT, require('./config.js').TCP_LISTEN);
+}
+