Add Tonuino Manager website and Sparkle updates
This commit is contained in:
commit
1cac427ff2
44 files changed
+5861
No files matched your search
@@ -0,0 +1,303 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
final class TonuinoSerialService: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var descriptor: Int32 = -1
|
||||
private var buffer = Data()
|
||||
private var outputMonitor: Task<Void, Never>?
|
||||
private(set) var firmwareVersion: String?
|
||||
static var ports: [String] {
|
||||
((try? FileManager.default.contentsOfDirectory(atPath: "/dev")) ?? [])
|
||||
.filter { $0.hasPrefix("cu.") }.map { "/dev/\($0)" }.sorted()
|
||||
}
|
||||
var isConnected: Bool { lock.lock(); defer { lock.unlock() }; return descriptor >= 0 }
|
||||
|
||||
func connect(_ path: String) throws -> (firmwareVersion: String?, settings: TonuinoDeviceSettings?) {
|
||||
disconnect()
|
||||
let fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK)
|
||||
guard fd >= 0 else { throw SerialError.open(path, String(cString: strerror(errno))) }
|
||||
var termiosConfig = termios()
|
||||
guard tcgetattr(fd, &termiosConfig) == 0 else { close(fd); throw SerialError.configure }
|
||||
cfmakeraw(&termiosConfig); cfsetispeed(&termiosConfig, speed_t(B115200)); cfsetospeed(&termiosConfig, speed_t(B115200))
|
||||
termiosConfig.c_cflag |= tcflag_t(CLOCAL | CREAD)
|
||||
termiosConfig.c_cflag &= ~tcflag_t(CSTOPB | PARENB | CSIZE)
|
||||
termiosConfig.c_cflag |= tcflag_t(CS8)
|
||||
guard tcsetattr(fd, TCSANOW, &termiosConfig) == 0 else { close(fd); throw SerialError.configure }
|
||||
tcflush(fd, TCIOFLUSH)
|
||||
lock.lock(); descriptor = fd; buffer.removeAll(); lock.unlock()
|
||||
firmwareVersion = nil
|
||||
var parsedSettings = TonuinoDeviceSettings()
|
||||
let startupDeadline = Date().addingTimeInterval(3) // many TonUINO boards reset when serial is opened
|
||||
while Date() < startupDeadline {
|
||||
let remaining = startupDeadline.timeIntervalSinceNow
|
||||
guard let line = try readLine(timeout: min(0.15, max(remaining, 0))) else { continue }
|
||||
if let range = line.range(of: #"V\d+\.\d+\.\d+[^\r\n]*"#, options: .regularExpression) {
|
||||
firmwareVersion = String(line[range])
|
||||
}
|
||||
parsedSettings.readLogLine(line)
|
||||
}
|
||||
let capturedSettings = parsedSettings.hasValues ? parsedSettings : nil
|
||||
outputMonitor = Task.detached(priority: .utility) { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
do { _ = try self.readLine(timeout: 0.3) }
|
||||
catch { return }
|
||||
}
|
||||
}
|
||||
return (firmwareVersion, capturedSettings)
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
outputMonitor?.cancel()
|
||||
outputMonitor = nil
|
||||
lock.lock(); let old = descriptor; descriptor = -1; buffer.removeAll(); lock.unlock()
|
||||
if old >= 0 { close(old) }
|
||||
}
|
||||
|
||||
func writeCard(_ card: TonuinoCard) async throws {
|
||||
try card.validate()
|
||||
guard card.modifier == nil else {
|
||||
throw SerialError.invalid("Sonderkarten können mit diesem seriellen Schreibassistenten nicht programmiert werden. Verwende einen PC/SC-Reader oder exportiere einen Dump.")
|
||||
}
|
||||
// TonUINO-TNG has no WRITECARD serial command. With SerialInputAsCommand
|
||||
// enabled, its documented numeric commands can drive the built-in
|
||||
// Admin > New card voice menu instead.
|
||||
try write("-4\n") // allLong: open the admin menu while TonUINO is idle
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
try await selectMenuItem(1) // Admin: create new card
|
||||
let mode = card.mode == .admin ? 6 : card.mode.rawValue
|
||||
try await selectMenuItem(mode)
|
||||
|
||||
if card.mode.needsFolder, ![.admin, .repeatLast, .bluetooth].contains(card.mode) {
|
||||
try await selectMenuItem(Int(card.folder))
|
||||
}
|
||||
|
||||
switch card.mode {
|
||||
case .single:
|
||||
try await selectMenuItem(Int(card.special))
|
||||
case .hoerspielRange, .albumRange, .partyRange, .audiobookRange:
|
||||
try await selectMenuItem(Int(card.special))
|
||||
try await selectMenuItem(Int(card.special2))
|
||||
case .audiobook:
|
||||
try await selectMenuItem(Int(card.folder) + Int(card.special2))
|
||||
case .audiobookSingle:
|
||||
try await selectMenuItem(Int(card.folder) + Int(card.special2))
|
||||
try await selectMenuItem(Int(card.special) + 1)
|
||||
case .quiz:
|
||||
let option: Int
|
||||
switch (card.special, card.special2) {
|
||||
case (2, 0): option = 1
|
||||
case (4, 0): option = 2
|
||||
case (2, 1): option = 3
|
||||
case (4, 1): option = 4
|
||||
case (0, 1): option = 5
|
||||
default: throw SerialError.invalid("Ungültige Quiz-Optionen.")
|
||||
}
|
||||
try await selectMenuItem(option)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func cancelWrite() throws { try exitAdminMenu() }
|
||||
func exitAdminMenu() throws { try write("-6\n") }
|
||||
|
||||
func applyAdminSetting(menuItem: Int, option: Int, currentPIN: String = "", newPIN: String = "") async throws {
|
||||
guard (2...14).contains(menuItem), (1...99).contains(option) else {
|
||||
throw SerialError.invalid("Ungültige TonUINO-Menüauswahl.")
|
||||
}
|
||||
if menuItem == 12, option == 3 { try validatePIN(newPIN) }
|
||||
try await enterAdmin(currentPIN: currentPIN)
|
||||
try await selectMenuItem(menuItem)
|
||||
try await selectMenuItem(option)
|
||||
if menuItem == 12, option == 3 {
|
||||
try await sendPIN(newPIN)
|
||||
}
|
||||
}
|
||||
|
||||
func applyVolumeSettings(minimum: Int, maximum: Int, initial: Int, currentPIN: String = "") async throws {
|
||||
guard (1...29).contains(minimum), (2...30).contains(maximum), minimum < maximum,
|
||||
(minimum...maximum).contains(initial) else {
|
||||
throw SerialError.invalid("Lautstärke: Minimum muss unter dem Maximum liegen; Startlautstärke muss dazwischen liegen.")
|
||||
}
|
||||
|
||||
try await enterAdmin(currentPIN: currentPIN)
|
||||
// Set a known intermediate range so subsequent voice-menu indices are deterministic.
|
||||
try await selectMenuItem(3) // Minimum volume
|
||||
try await selectMenuItem(1)
|
||||
try await selectMenuItem(2) // Maximum volume
|
||||
try await selectMenuItem(29) // 30, now that minimum is 1
|
||||
try await selectMenuItem(3)
|
||||
try await selectMenuItem(minimum)
|
||||
try await selectMenuItem(2)
|
||||
try await selectMenuItem(maximum - minimum)
|
||||
try await selectMenuItem(4)
|
||||
try await selectMenuItem(initial - minimum + 1)
|
||||
}
|
||||
|
||||
func resetAdminSettings(currentPIN: String = "") async throws {
|
||||
try await enterAdmin(currentPIN: currentPIN)
|
||||
try await selectMenuItem(11)
|
||||
// The reset state consumes one command to return to Admin_Entry.
|
||||
try await Task.sleep(for: .milliseconds(250))
|
||||
try write("-6\n")
|
||||
try await Task.sleep(for: .milliseconds(250))
|
||||
try write("-6\n")
|
||||
try await Task.sleep(for: .milliseconds(250))
|
||||
}
|
||||
|
||||
private func enterAdmin(currentPIN: String) async throws {
|
||||
if !currentPIN.isEmpty { try validatePIN(currentPIN) }
|
||||
try write("-4\n") // allLong: enter Admin while idle
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
if !currentPIN.isEmpty { try await sendPIN(currentPIN) }
|
||||
}
|
||||
|
||||
private func sendPIN(_ pin: String) async throws {
|
||||
let commands: [Character: Int] = ["1": -5, "2": -8, "3": -2]
|
||||
for digit in pin {
|
||||
try Task.checkCancellation()
|
||||
try write("\(commands[digit]!)\n")
|
||||
try await Task.sleep(for: .milliseconds(220))
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(250))
|
||||
}
|
||||
|
||||
private func validatePIN(_ pin: String) throws {
|
||||
guard pin.count == 4, pin.allSatisfy({ ["1", "2", "3"].contains(String($0)) }) else {
|
||||
throw SerialError.invalid("Die TonUINO-PIN muss aus vier Ziffern 1, 2 oder 3 bestehen.")
|
||||
}
|
||||
}
|
||||
|
||||
private func selectMenuItem(_ value: Int) async throws {
|
||||
try Task.checkCancellation()
|
||||
try write("\(value)\n")
|
||||
try await Task.sleep(for: .milliseconds(180))
|
||||
try write("-5\n") // pause: select the current voice-menu item
|
||||
try await Task.sleep(for: .milliseconds(250))
|
||||
}
|
||||
|
||||
private func write(_ text: String) throws {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
guard descriptor >= 0 else { throw SerialError.disconnected }
|
||||
let bytes = Array(text.utf8)
|
||||
let result = Darwin.write(descriptor, bytes, bytes.count)
|
||||
guard result == bytes.count else { throw SerialError.io(String(cString: strerror(errno))) }
|
||||
}
|
||||
|
||||
private func readLine(timeout: TimeInterval) throws -> String? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
lock.lock(); let fd = descriptor; lock.unlock()
|
||||
guard fd >= 0 else { throw SerialError.disconnected }
|
||||
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
if Darwin.poll(&pollDescriptor, 1, 100) > 0 {
|
||||
var bytes = [UInt8](repeating: 0, count: 256)
|
||||
let count = Darwin.read(fd, &bytes, bytes.count)
|
||||
if count > 0 {
|
||||
lock.lock(); buffer.append(contentsOf: bytes.prefix(count))
|
||||
if let newline = buffer.firstIndex(of: 10) {
|
||||
let line = Data(buffer[..<newline]); buffer.removeSubrange(...newline); lock.unlock()
|
||||
return String(data: line, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Values printed by current TonUINO-TNG AiO Plus firmware during startup.
|
||||
/// Older AiO firmware may not print these lines, so every field is optional.
|
||||
struct TonuinoDeviceSettings: Equatable {
|
||||
var firmwareSettingsVersion: Int?
|
||||
var speakerMaximum: Int?
|
||||
var speakerMinimum: Int?
|
||||
var speakerInitial: Int?
|
||||
var headphoneMaximum: Int?
|
||||
var headphoneMinimum: Int?
|
||||
var headphoneInitial: Int?
|
||||
var equalizer: Int?
|
||||
var standbyTimer: Int?
|
||||
var invertedVolumeButtons: Int?
|
||||
var adminMenuLocked: Int?
|
||||
var pauseWhenCardRemoved: Int?
|
||||
|
||||
var hasValues: Bool {
|
||||
firmwareSettingsVersion != nil || speakerMaximum != nil || speakerMinimum != nil || speakerInitial != nil ||
|
||||
headphoneMaximum != nil || headphoneMinimum != nil || headphoneInitial != nil || equalizer != nil ||
|
||||
standbyTimer != nil || invertedVolumeButtons != nil || adminMenuLocked != nil || pauseWhenCardRemoved != nil
|
||||
}
|
||||
|
||||
mutating func readLogLine(_ line: String) {
|
||||
if let values = values(after: "Ver:", in: line), let first = values.first { firmwareSettingsVersion = first }
|
||||
if let values = values(after: "Vol:", in: line) {
|
||||
if values.indices.contains(0) { speakerMaximum = values[0] }
|
||||
if values.indices.contains(1) { speakerMinimum = values[1] }
|
||||
if values.indices.contains(2) { speakerInitial = values[2] }
|
||||
if values.indices.contains(3) { headphoneMaximum = values[3] }
|
||||
if values.indices.contains(4) { headphoneMinimum = values[4] }
|
||||
if values.indices.contains(5) { headphoneInitial = values[5] }
|
||||
}
|
||||
if let values = values(after: "EQ:", in: line), let first = values.first { equalizer = first }
|
||||
if let values = values(after: "ST:", in: line), let first = values.first { standbyTimer = first }
|
||||
if let values = values(after: "IB:", in: line), let first = values.first { invertedVolumeButtons = first }
|
||||
if let values = values(after: "AL:", in: line), let first = values.first { adminMenuLocked = first }
|
||||
if let values = values(after: "PCR:", in: line), let first = values.first { pauseWhenCardRemoved = first }
|
||||
// AP (the admin PIN) is intentionally neither parsed nor retained.
|
||||
}
|
||||
|
||||
private func values(after marker: String, in line: String) -> [Int]? {
|
||||
guard let range = line.range(of: NSRegularExpression.escapedPattern(for: marker), options: [.regularExpression, .caseInsensitive]) else { return nil }
|
||||
let remainder = line[range.upperBound...]
|
||||
let text = String(remainder)
|
||||
let pattern = #"-?\d+"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let nsRange = NSRange(text.startIndex..., in: text)
|
||||
return regex.matches(in: text, range: nsRange).compactMap { match in
|
||||
guard let matchRange = Range(match.range, in: text) else { return nil }
|
||||
return Int(text[matchRange])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TonuinoCard {
|
||||
func validate() throws {
|
||||
if let modifier {
|
||||
switch modifier {
|
||||
case .sleepTimer:
|
||||
guard special > 0 else { throw SerialError.invalid("Der Einschlaf-Timer muss mindestens eine Minute dauern.") }
|
||||
case .freezeDance, .fireWaterAir:
|
||||
guard special <= 2 else { throw SerialError.invalid("Die Pausenstufe muss zwischen 0 und 2 liegen.") }
|
||||
default:
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
if mode.needsFolder, mode != .admin, !(1...99).contains(Int(folder)) { throw SerialError.invalid("Ordner muss zwischen 01 und 99 liegen.") }
|
||||
if mode == .single && special == 0 { throw SerialError.invalid("Bitte einen Track größer 0 auswählen.") }
|
||||
if mode == .audiobookSingle && special >= 30 { throw SerialError.invalid("Hörbuch einzeln akzeptiert maximal 29 Zusatztracks.") }
|
||||
if [.hoerspielRange, .albumRange, .partyRange, .audiobookRange].contains(mode), special == 0 || special > special2 {
|
||||
throw SerialError.invalid("Der Trackbereich muss von 1 bis zum Endtrack reichen.")
|
||||
}
|
||||
if mode == .quiz && !([0, 2, 4].contains(Int(special)) && [0, 1].contains(Int(special2))) { throw SerialError.invalid("Ungültige Quiz-Optionen.") }
|
||||
}
|
||||
}
|
||||
|
||||
enum SerialError: LocalizedError {
|
||||
case open(String, String), configure, disconnected, io(String), timeout, firmware(String), invalid(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .open(let p, let e): "Serieller Port \(p) kann nicht geöffnet werden: \(e)"
|
||||
case .configure: "Der serielle Port konnte nicht auf 115200 Baud konfiguriert werden."
|
||||
case .disconnected: "Keine Verbindung zum TonUINO."
|
||||
case .io(let e): "Serielle Kommunikation fehlgeschlagen: \(e)"
|
||||
case .timeout: "Zeitüberschreitung. Prüfe, ob keine Karte auf dem TonUINO-Leser liegt und die Firmware SerialInputAsCommand aktiviert hat."
|
||||
case .firmware(let text): text
|
||||
case .invalid(let text): text
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user