304 lines
14 KiB
Swift
304 lines
14 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|