diff --git a/html/webpage/assets/js/dragdrop.js b/html/webpage/assets/js/dragdrop.js new file mode 100644 index 0000000..a3082cb --- /dev/null +++ b/html/webpage/assets/js/dragdrop.js @@ -0,0 +1,31 @@ +const dropArea = document.getElementById("drop-area"); +const fileInput = document.getElementById("file-input"); + +['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + dropArea.addEventListener(eventName, e => e.preventDefault()); + dropArea.addEventListener(eventName, e => e.stopPropagation()); +}); + +['dragenter', 'dragover'].forEach(eventName => { + dropArea.addEventListener(eventName, () => dropArea.classList.add('highlight')); +}); + +['dragleave', 'drop'].forEach(eventName => { + dropArea.addEventListener(eventName, () => dropArea.classList.remove('highlight')); +}); + + +dropArea.addEventListener('click', () => fileInput.click()); + +dropArea.addEventListener('drop', e => { + const files = e.dataTransfer.files; + handleFiles(files); +}); + +fileInput.addEventListener('change', e => { + handleFiles(e.target.files); +}); + +function handleFiles(files) { + console.log("file dropped"); +} diff --git a/html/webpage/broadcastzones.html b/html/webpage/broadcastzones.html index 6a656b2..ef34350 100644 --- a/html/webpage/broadcastzones.html +++ b/html/webpage/broadcastzones.html @@ -195,9 +195,9 @@ - - - + + + @@ -236,11 +236,11 @@
NoDescriptionIP AddressNoDescriptionIP Address
- - - - - + + + + + diff --git a/html/webpage/setting.html b/html/webpage/setting.html index ae89f21..006b5b5 100644 --- a/html/webpage/setting.html +++ b/html/webpage/setting.html @@ -80,6 +80,7 @@ + \ No newline at end of file diff --git a/src/toa/Vx3K.kt b/src/toa/Vx3K.kt new file mode 100644 index 0000000..fe713fd --- /dev/null +++ b/src/toa/Vx3K.kt @@ -0,0 +1,266 @@ +package toa + +import codes.Somecodes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.tinylog.Logger +import java.net.Inet4Address +import java.net.InetSocketAddress +import java.net.Socket +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.function.BiConsumer + +/** + * VX3K Protocol + * @param ipaddress IP address of the VX3K device, default to 192.168.14.1 + * @param port Port number of the VX3K device, from 50050-50053 default to 50053 + */ +class Vx3K(val ipaddress : String = "192.168.14.1", val port : Int = 50053) { + private val remotesocket : InetSocketAddress + init{ + if (port !in 50050..50053){ + throw IllegalArgumentException("Port number must be between 50050 and 50053") + } + try{ + val inet = Inet4Address.getByName(ipaddress) + remotesocket = InetSocketAddress(inet, port) + } catch (_ : Exception){ + throw IllegalArgumentException("Invalid IP address: $ipaddress") + } + } + + /** + * Connect to the VX3K device + * @param timeout Connection timeout in milliseconds, default to 30000 ms + */ + fun Connect(timeout: Int = 30000){ + try{ + val socket = Socket() + // read timeout 5 seconds + socket.soTimeout = 5000 + socket.connect(remotesocket, timeout) + } catch (e : Exception){ + Logger.error { "Failed to connect with ${remotesocket.hostName}:${remotesocket.port}, Message: ${e.message}" } + } + } + + /** + * Virtual Contact Input (Commmand 0x1001) + * @param ID : Device ID for VX3K, range 0 - 31 + * @param CIN : Contact Input Number, range 0 - 15 for normal terminal, 16 for emergency contact input1, 17 for emergency contact input2 + * @param isON : true for ON, false for OFF + * @param cb : Callback function with parameters (success: Boolean, message: String) + */ + fun VirtualCIN(ID: Short, CIN: Short, isON: Boolean, cb : BiConsumer){ + val commandID = 0x1001.toShort() + if (ID !in 0..31){ + cb.accept(false, "ID must be between 0 and 31") + return + } + if (CIN !in 0..17){ + cb.accept(false, "CIN must be between 0 and 17") + return + } + val payload = ByteBuffer.allocate(6).order(ByteOrder.BIG_ENDIAN) + payload.putShort(ID) + payload.putShort(CIN) + payload.putShort(if (isON) 1 else 0) + val command = Make_Request_Command(commandID, payload.array()) + Send_Receive(command,8){ + success, reply -> + if (success){ + val bb = ByteBuffer.wrap(reply).order(ByteOrder.BIG_ENDIAN) + val resp_commandID = bb.short + val resp_code = bb.short + if (resp_commandID==commandID){ + if (resp_code.toInt() == 0){ + cb.accept(true, "Virtual CIN command sent successfully to ${remotesocket.hostName}:${remotesocket.port}") + } else { + cb.accept(false, "Virtual CIN command failed with response code $resp_code from ${remotesocket.hostName}:${remotesocket.port}") + } + } else { + cb.accept(false, "Invalid response command ID $resp_commandID from ${remotesocket.hostName}:${remotesocket.port}") + } + } else { + cb.accept(false, "Failed to send Virtual CIN command to ${remotesocket.hostName}:${remotesocket.port}") + } + } + } + + /** + * Open / Close Audio Input in Broadcast Pattern (Command 0x1003 for old firmware, 0x1101 for new firmware) + * @param NewFirmware Set to true if using new firmware version + * @param ID Device ID for VX3K, range 0 - 39 for new firmware, 0 - 31 for old firmware + * @param Channel Audio Input Channel, range 0 - 3 + * @param BroadcastZones Vx3K_BroadcastZone object with selected broadcast zones + * @param cb Callback function with parameters (success: Boolean, message: String) + */ + fun AudioInput_BroadcastPattern(NewFirmware : Boolean, ID: Short, Channel: Short, BroadcastZones: Vx3K_BroadcastZone, cb: BiConsumer){ + val CommandID = if (NewFirmware) 0x1101.toShort() else 0x1003.toShort() + if (NewFirmware){ + if (ID !in 0..39){ + cb.accept(false, "ID must be between 0 and 39") + return + } + } else { + if (ID !in 0..31){ + cb.accept(false, "ID must be between 0 and 31") + return + } + } + if (Channel !in 0..3){ + cb.accept(false, "Channel must be between 0 and 3") + return + } + + val payload = ByteBuffer.allocate(if (NewFirmware) 86 else 70).order(ByteOrder.BIG_ENDIAN) + // Audio Input = 1 + payload.putShort(1) + // VX3K ID + payload.putShort(ID) + // Audio Input Channel + payload.putShort(Channel) + // Broadcast Pattern Zones + payload.put(BroadcastZones.payload) + val command = Make_Request_Command(CommandID, payload.array()) + Send_Receive(command,8){ + success, reply -> + if (success){ + val bb = ByteBuffer.wrap(reply).order(ByteOrder.BIG_ENDIAN) + val resp_commandID = bb.short + val resp_code = bb.short + if (resp_commandID==CommandID){ + if (resp_code.toInt() == 0){ + cb.accept(true, "Open Audio Input Broadcast command sent successfully to ${remotesocket.hostName}:${remotesocket.port}") + } else { + cb.accept(false, "Open Audio Input Broadcast command failed with response code $resp_code from ${remotesocket.hostName}:${remotesocket.port}") + } + } else { + cb.accept(false, "Invalid response command ID $resp_commandID from ${remotesocket.hostName}:${remotesocket.port}") + } + } else { + cb.accept(false, "Failed to send Open Audio Input Broadcast command to ${remotesocket.hostName}:${remotesocket.port}") + } + } + } + + /** + * Open / Close Network Broadcast Pattern (Command 0x1102 for old firmware, 0x1104 for new firmware) + * @param NewFirmware Set to true if using new firmware version + * @param multicastIP Multicast IP address in string format, from 224.0.0.0 ~ 239.255.255.255, default to 224.0.0.1 + * @param port UDP Port number, port 5000-5255 is invalid, default to 5300 + * @param priority Broadcast Priority value, range 1 - 1024, default to 512 (to be checked later) + * @param isBGM true for BGM, false for Paging + * @param SSRC SSRC value, range 1 - 65535, default to 1 (to be checked later) + * @param payloadType RTP Payload Type, range 0 - 127, default to 0 (to be checked later) + * @param payloadSize RTP Payload Size, range 0 - 1500, default to 1000 (to be checked later) + * @param BroadcastZones Vx3K_BroadcastZone object with selected broadcast zones + * @param cb Callback function with parameters (success: Boolean, message: String) + */ + fun Network_BroadcastPattern(NewFirmware: Boolean,multicastIP: String = "224.0.0.1", port: Int = 5300, priority: Int = 512, isBGM: Boolean, SSRC: UShort = 1u, payloadType: Short = 0, payloadSize: Short = 1000, BroadcastZones: Vx3K_BroadcastZone, cb:BiConsumer){ + val CommandID = if (NewFirmware) 0x1104.toShort() else 0x1102.toShort() + if (Somecodes.ValidIPV4(multicastIP)){ + val inet = Inet4Address.getByName(multicastIP) + if (inet.isMulticastAddress){ + if (port in 5256..65535){ + if (priority in 1..1024){ + if (SSRC in 1u..65535u){ + if (payloadType in 0..127){ + if (payloadSize in 0..1500){ + val jitterbuffer = 1000 //TODO 32 signed integer, to be checked later + val payload = ByteBuffer.allocate(if (NewFirmware) 100 else 84) + payload.put(inet.address) + payload.putShort(port.toShort()) + payload.putShort(priority.toShort()) + payload.putShort(if (isBGM) 1 else 0) + payload.putShort(SSRC.toShort()) + payload.putShort(payloadType) + payload.putShort(payloadSize) + payload.putInt(jitterbuffer) + payload.put(BroadcastZones.payload) + val command = Make_Request_Command(CommandID, payload.array()) + Send_Receive(command,10){ + success, reply -> + if (success){ + val bb = ByteBuffer.wrap(reply).order(ByteOrder.BIG_ENDIAN) + val resp_commandID = bb.short + val resp_code = bb.short + // buang 2 short + bb.short + bb.short + val sound_source_number = bb.short + if (resp_commandID==CommandID){ + if (resp_code.toInt() == 0){ + cb.accept(true, "Open Network Broadcast command sent successfully to ${remotesocket.hostName}:${remotesocket.port}, Sound Source Number: $sound_source_number") + } else { + cb.accept(false, "Open Network Broadcast command failed with response code $resp_code from ${remotesocket.hostName}:${remotesocket.port}") + } + } else { + cb.accept(false, "Invalid response command ID $resp_commandID from ${remotesocket.hostName}:${remotesocket.port}") + } + } else { + cb.accept(false, "Failed to send Open Network Broadcast command to ${remotesocket.hostName}:${remotesocket.port}") + } + } + } else cb.accept(false, "Payload Size must be between 0 and 1500") + } else cb.accept(false, "Payload Type must be between 0 and 127") + } else cb.accept(false, "SSRC must be between 1 and 65535") + } else cb.accept(false, "Priority must be between 1 and 1024") + } else cb.accept(false, "Port must be greater than 5255 and less than 65536") + } else cb.accept(false, "multicastIP must between 224.0.0.0 ~ 239.255.255.255") + } else cb.accept(false, "Multicast IP address invalid") + } + + + + /** + * Send command and receive reply from VX3K device + * @param command Command byte array to send + * @param expectedlength Expected length of the reply byte array + * @param cb Callback function with parameters (success: Boolean, reply: ByteArray) + */ + private fun Send_Receive(command: ByteArray, expectedlength: Int, cb : BiConsumer){ + CoroutineScope(Dispatchers.IO).launch { + try{ + val tcp = Socket() + tcp.soTimeout = 5000 + tcp.connect(remotesocket, 30000) + val outstream = tcp.getOutputStream() + val instream = tcp.getInputStream() + outstream.write(command) + outstream.flush() + val reply = ByteArray(expectedlength) + instream.read(reply) + outstream.close() + instream.close() + tcp.close() + cb.accept(true, reply) + } catch (_: Exception){ + cb.accept(false, ByteArray(0)) + } + } + } + + /** + * Wrap payload into VX3K Request Command format + * @param commandID Command ID + * @param Payload Payload data + * @return ByteArray of the complete command + */ + private fun Make_Request_Command(commandID: Short, Payload: ByteArray) : ByteArray { + val result = ByteBuffer.allocate(8+Payload.size).order(ByteOrder.BIG_ENDIAN) + // command ID (2 bytes) + result.putShort(commandID) + // Response code (2 bytes), set 0 for Request + result.putShort(0) + // command length = command header (8 bytes) + payload size + result.putShort((Payload.size+8).toShort()) + // flag and reserver = 0 (2 bytes) + result.putShort(0) + result.put(Payload) + // read to byte array + return result.array() + } +} \ No newline at end of file diff --git a/src/toa/Vx3K_BroadcastZone.kt b/src/toa/Vx3K_BroadcastZone.kt new file mode 100644 index 0000000..aab2722 --- /dev/null +++ b/src/toa/Vx3K_BroadcastZone.kt @@ -0,0 +1,67 @@ +package toa + +import kotlin.experimental.and +import kotlin.experimental.or + +/** + * VX3K Broadcast Zone Configuration + * Old Firmware: Broadcast Zone 1 - 512 + * New Firmware: Broadcast Zone 1 - 640 + * @param NewFirmware Set to true if using new firmware version + */ +@Suppress("unused") +class Vx3K_BroadcastZone(val NewFirmware: Boolean = false) { + val payload: ByteArray = if (NewFirmware) ByteArray(80) else ByteArray(64) + + /** + * Set a zone as active + * @param zonenumber Zone number to set (1 to 512 for old firmware, 1 to 640 for new firmware) + */ + fun SetZone(zonenumber: Int){ + if (zonenumber<1) throw Exception("Minimum zone number is 1") + if (NewFirmware){ + if (zonenumber>640) throw Exception("Maximum zone number is 640 for new firmware") + } else { + if (zonenumber>512) throw Exception("Maximum zone number is 512 for old firmware") + } + + val byteIndex = (zonenumber - 1) / 8 + val bitIndex = (zonenumber - 1) % 8 + payload[byteIndex] = payload[byteIndex] or (1 shl bitIndex).toByte() + } + + /** + * Clear a zone (set as inactive) + * @param zonenumber Zone number to clear (1 to 512 for old firmware, 1 to 640 for new firmware) + */ + fun ClearZone(zonenumber: Int){ + if (zonenumber<1) throw Exception("Minimum zone number is 1") + if (NewFirmware){ + if (zonenumber>640) throw Exception("Maximum zone number is 640 for new firmware") + } else { + if (zonenumber>512) throw Exception("Maximum zone number is 512 for old firmware") + } + + val byteIndex = (zonenumber - 1) / 8 + val bitIndex = (zonenumber - 1) % 8 + payload[byteIndex] = payload[byteIndex] and ((1 shl bitIndex).inv().toByte()) + } + + /** + * Set all zones as active + */ + fun SetAllZones(){ + for (i in payload.indices){ + payload[i] = 0xFF.toByte() + } + } + + /** + * Clear all zones (set all as inactive) + */ + fun ClearAllZones(){ + for (i in payload.indices){ + payload[i] = 0 + } + } +} \ No newline at end of file
NoDescriptionSoundChannelIDBPNoDescriptionSoundChannelIDBP