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,282 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct CardsView: View {
|
||||
@EnvironmentObject private var app: AppModel
|
||||
@State private var route = CardRoute.tonuino
|
||||
@State private var folder = 1
|
||||
@State private var mode: PlaybackMode = .album
|
||||
@State private var category: CardEditorCategory = .playback
|
||||
@State private var modifier: TonuinoModifier = .sleepTimer
|
||||
@State private var special = 1
|
||||
@State private var special2 = 1
|
||||
@State private var sleepTimerMinutes = 30
|
||||
@State private var stopAfterCurrentTrack = false
|
||||
@State private var danceDifficulty = 1
|
||||
@State private var busy = false
|
||||
@State private var tonuinoWritePending = false
|
||||
@State private var lastRead: TonuinoCard?
|
||||
@State private var lastUID = ""
|
||||
@AppStorage("reader.route") private var defaultRoute = "tonuino"
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("NFC-Karten").font(.largeTitle.bold())
|
||||
Text("Ordnerkarten, Wiedergabemodi und Admin-Karten programmieren oder auslesen.").foregroundStyle(.secondary)
|
||||
}.padding()
|
||||
Divider()
|
||||
Form {
|
||||
Section("Programmierweg") {
|
||||
Picker("Kartenleser", selection: $route) { ForEach(CardRoute.allCases) { Text($0.title).tag($0) } }
|
||||
.pickerStyle(.segmented)
|
||||
if route == .tonuino { serialControls }
|
||||
else { readerControls }
|
||||
}
|
||||
Section("Karteninhalt") {
|
||||
Picker("Kategorie", selection: $category) {
|
||||
ForEach(CardEditorCategory.allCases) { Text($0.title).tag($0) }
|
||||
}
|
||||
if category == .playback {
|
||||
Picker("Kartenart", selection: $mode) { ForEach(PlaybackMode.allCases) { Text($0.title).tag($0) } }
|
||||
if mode.needsFolder && mode != .admin {
|
||||
if let library = app.library.folders.first(where: { $0.number == folder }) {
|
||||
Picker("Ordner", selection: $folder) {
|
||||
ForEach(app.library.folders) { item in Text("\(String(format: "%02d", item.number)) — \(item.title)").tag(item.number) }
|
||||
}
|
||||
.onAppear { if !app.library.folders.contains(where: { $0.number == folder }) { folder = library.number } }
|
||||
} else {
|
||||
Stepper("Ordner \(String(format: "%02d", folder))", value: $folder, in: 1...99)
|
||||
}
|
||||
}
|
||||
if mode.needsSpecial && mode != .quiz { Stepper(specialLabel, value: $special, in: mode == .audiobookSingle ? 0...29 : 1...255) }
|
||||
if mode.needsSpecial2 {
|
||||
if mode == .quiz {
|
||||
Picker("Fragen", selection: $special) { Text("Alle").tag(0); Text("2 Fragen").tag(2); Text("4 Fragen").tag(4) }
|
||||
Picker("Antworten pro Frage", selection: $special2) { Text("2").tag(0); Text("3").tag(1) }
|
||||
} else { Stepper("Endtrack: \(special2)", value: $special2, in: max(1, special)...255) }
|
||||
}
|
||||
Text("\(mode.title)" + (mode.needsFolder ? " · Ordner \(String(format: "%02d", folder))" : ""))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Picker("Sonderkarte", selection: $modifier) {
|
||||
ForEach(TonuinoModifier.allCases) { Text($0.title).tag($0) }
|
||||
}
|
||||
switch modifier {
|
||||
case .sleepTimer:
|
||||
Stepper("Laufzeit: \(sleepTimerMinutes) Minuten", value: $sleepTimerMinutes,
|
||||
in: 1...(stopAfterCurrentTrack ? 127 : 255))
|
||||
Toggle("Nach dem aktuellen Track stoppen", isOn: $stopAfterCurrentTrack)
|
||||
case .freezeDance, .fireWaterAir:
|
||||
Picker("Pausenstufe", selection: $danceDifficulty) {
|
||||
Text("1").tag(0); Text("2").tag(1); Text("3").tag(2)
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
if let note = modifier.firmwareNote {
|
||||
Label(note, systemImage: "info.circle")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Text(modifier.title + " · Ordnernummer 00")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Section("Karte") {
|
||||
HStack {
|
||||
Label(lastUID.isEmpty ? "Keine Karte gelesen" : "UID \(lastUID)", systemImage: "wave.3.right")
|
||||
Spacer()
|
||||
if !lastUID.isEmpty {
|
||||
Text(lastRead.map { $0.displayTitle + ($0.folder > 0 ? " · Ordner \(String(format: "%02d", $0.folder))" : "") } ?? "Unprogrammierte Karte")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Button("Karte lesen", systemImage: "doc.text.magnifyingglass") { Task { await readCard() } }.disabled(busy || route != .acr122u)
|
||||
Button("Programmieren", systemImage: "wave.3.right", role: .none) { Task { await writeCard() } }
|
||||
.buttonStyle(.borderedProminent).disabled(busy || tonuinoWritePending || !isRouteReady || (category == .modifier && route == .tonuino))
|
||||
Button("Dump exportieren…", systemImage: "square.and.arrow.up") { exportDump() }
|
||||
.disabled(busy || tonuinoWritePending)
|
||||
if tonuinoWritePending {
|
||||
Button("Abbrechen", role: .cancel) {
|
||||
do { try app.cards.cancelWrite(); tonuinoWritePending = false }
|
||||
catch { app.report(error) }
|
||||
}
|
||||
Button("Fertig") {
|
||||
do { try app.cards.cancelWrite(); tonuinoWritePending = false }
|
||||
catch { app.report(error) }
|
||||
}
|
||||
}
|
||||
if busy && route == .tonuino {
|
||||
Button("Abbrechen", role: .cancel) { do { try app.cards.cancelWrite() } catch { app.report(error) } }
|
||||
}
|
||||
if route == .acr122u {
|
||||
Button("Karte löschen", systemImage: "eraser", role: .destructive) { Task { await eraseCard() } }.disabled(busy || !isRouteReady)
|
||||
}
|
||||
if busy { ProgressView().controlSize(.small); Text("TonUINO-Menü wird vorbereitet…").foregroundStyle(.secondary) }
|
||||
else if tonuinoWritePending { Label("Karte auf den TonUINO-Leser legen", systemImage: "wave.3.right").foregroundStyle(.secondary) }
|
||||
}
|
||||
if category == .modifier && route == .tonuino {
|
||||
Label("Der serielle TonUINO-Menüassistent kann diese Sonderkarte nicht anlegen. Nutze einen ACR122U-Reader oder exportiere einen Dump.", systemImage: "info.circle")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}.formStyle(.grouped).scrollContentBackground(.hidden)
|
||||
Spacer(minLength: 0)
|
||||
Text(route == .tonuino
|
||||
? "TonUINO muss im Leerlauf sein. Für USB-Steuerung muss SerialInputAsCommand in der Firmware aktiviert sein. Folge den Audioansagen und lege die Karte auf, wenn TonUINO dazu auffordert."
|
||||
: "ACR122U unterstützt MIFARE Mini, Classic 1K/4K und Ultralight/NTAG21x.")
|
||||
.font(.caption).foregroundStyle(.secondary).padding()
|
||||
}
|
||||
.onAppear {
|
||||
route = defaultRoute == "acr122u" ? .acr122u : .tonuino
|
||||
app.cards.refreshReaders(); app.cards.refreshSerialPorts()
|
||||
}
|
||||
.onChange(of: special) { newValue in
|
||||
if mode != .quiz, mode.needsSpecial2, special2 < newValue { special2 = newValue }
|
||||
}
|
||||
.onChange(of: stopAfterCurrentTrack) { enabled in
|
||||
if enabled { sleepTimerMinutes = min(sleepTimerMinutes, 127) }
|
||||
}
|
||||
.task(id: route) {
|
||||
guard route == .acr122u else { return }
|
||||
while !Task.isCancelled {
|
||||
if !busy {
|
||||
do { let (uid, card) = try await app.cards.readViaReader(); lastUID = uid; lastRead = card }
|
||||
catch { lastUID = ""; lastRead = nil }
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isRouteReady: Bool { route == .tonuino ? app.cards.serialConnected : !app.cards.selectedReader.isEmpty }
|
||||
private var specialLabel: String { mode == .single ? "Tracknummer: \(special)" : mode == .audiobookSingle ? "Zusatztracks: \(special)" : "Starttrack: \(special)" }
|
||||
|
||||
private var serialControls: some View {
|
||||
HStack {
|
||||
Picker("Port", selection: $app.cards.selectedSerialPort) { ForEach(app.cards.serialPorts, id: \.self) { Text($0).tag($0) } }
|
||||
Button("Aktualisieren", systemImage: "arrow.clockwise") { app.cards.refreshSerialPorts() }
|
||||
if app.cards.serialConnected {
|
||||
Label(app.cards.firmwareVersion.map { "Verbunden · \($0)" } ?? "Verbunden · Firmwareversion nicht erkannt", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Button("Trennen") { app.cards.disconnectSerial() }
|
||||
} else {
|
||||
Button("Verbinden") { Task { do { try await app.cards.connectSerial() } catch { app.report(error) } } }.disabled(app.cards.selectedSerialPort.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var readerControls: some View {
|
||||
HStack {
|
||||
Picker("Reader", selection: $app.cards.selectedReader) { ForEach(app.cards.readerNames, id: \.self) { Text($0).tag($0) } }
|
||||
Button("Reader suchen", systemImage: "arrow.clockwise") { app.cards.refreshReaders() }
|
||||
Text(app.cards.readerNames.isEmpty ? "Kein Smartcard-Reader gefunden" : "Reader bereit")
|
||||
.foregroundStyle(app.cards.readerNames.isEmpty ? Color.secondary : Color.green)
|
||||
}
|
||||
}
|
||||
|
||||
private func readCard() async {
|
||||
busy = true; defer { busy = false }
|
||||
do {
|
||||
let (uid, card) = try await app.cards.readViaReader()
|
||||
lastUID = uid
|
||||
lastRead = card
|
||||
if let card {
|
||||
folder = Int(card.folder)
|
||||
mode = card.mode
|
||||
special = Int(card.special)
|
||||
special2 = Int(card.special2)
|
||||
if let cardModifier = card.modifier {
|
||||
category = .modifier
|
||||
modifier = cardModifier
|
||||
if cardModifier == .sleepTimer {
|
||||
stopAfterCurrentTrack = card.special > 128
|
||||
sleepTimerMinutes = Int(card.special) - (stopAfterCurrentTrack ? 128 : 0)
|
||||
} else if cardModifier == .freezeDance || cardModifier == .fireWaterAir {
|
||||
danceDifficulty = min(Int(card.special), 2)
|
||||
}
|
||||
} else {
|
||||
category = .playback
|
||||
}
|
||||
}
|
||||
app.status = card == nil ? "Unprogrammierte Karte" : "Karte ausgelesen"
|
||||
}
|
||||
catch { app.report(error) }
|
||||
}
|
||||
private func writeCard() async {
|
||||
busy = true; defer { busy = false }
|
||||
do {
|
||||
let card = try makeCard()
|
||||
if route == .tonuino {
|
||||
try await app.cards.writeViaTonuino(card)
|
||||
tonuinoWritePending = true
|
||||
app.status = "TonUINO-Schreibassistent gestartet; folge den Audioansagen."
|
||||
} else {
|
||||
try await app.cards.writeViaReader(card)
|
||||
lastRead = card
|
||||
app.status = "Karte erfolgreich programmiert"
|
||||
}
|
||||
} catch { app.report(error) }
|
||||
}
|
||||
private func eraseCard() async {
|
||||
busy = true; defer { busy = false }
|
||||
do { try await app.cards.eraseViaReader(); lastRead = nil; app.status = "Karte gelöscht" } catch { app.report(error) }
|
||||
}
|
||||
private func makeCard() throws -> TonuinoCard {
|
||||
if category == .modifier {
|
||||
let parameter: UInt8
|
||||
switch modifier {
|
||||
case .sleepTimer:
|
||||
parameter = UInt8(sleepTimerMinutes + (stopAfterCurrentTrack ? 128 : 0))
|
||||
case .freezeDance, .fireWaterAir:
|
||||
parameter = UInt8(danceDifficulty)
|
||||
default:
|
||||
parameter = 0
|
||||
}
|
||||
let value = TonuinoCard.makeModifier(modifier, special: parameter)
|
||||
try value.validate()
|
||||
return value
|
||||
}
|
||||
let value = TonuinoCard(folder: mode == .admin ? 0 : UInt8(folder), mode: mode, special: UInt8(special), special2: UInt8(special2))
|
||||
try value.validate()
|
||||
return value
|
||||
}
|
||||
|
||||
private func exportDump() {
|
||||
do {
|
||||
let card = try makeCard()
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = dumpFilename(for: card)
|
||||
panel.message = "Speichere einen MIFARE-Classic-1K-Dump für den Pixl.js-Kartenemulator."
|
||||
panel.prompt = "Speichern"
|
||||
panel.canCreateDirectories = true
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
try MifareClassicDump.make1K(for: card).write(to: url, options: .atomic)
|
||||
app.status = "Kartendump exportiert: \(card.displayTitle)"
|
||||
} catch { app.report(error) }
|
||||
}
|
||||
|
||||
private func dumpFilename(for card: TonuinoCard) -> String {
|
||||
let title = card.displayTitle
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
.map { $0.isLetter || $0.isNumber ? $0 : "-" }
|
||||
.split(separator: "-")
|
||||
.joined(separator: "-")
|
||||
if card.modifier != nil { return "Tonuino-Sonderkarte-\(title).bin" }
|
||||
return "Tonuino-\(title)-Ordner-\(String(format: "%02d", card.folder)).bin"
|
||||
}
|
||||
}
|
||||
|
||||
private enum CardEditorCategory: String, CaseIterable, Identifiable {
|
||||
case playback, modifier
|
||||
var id: String { rawValue }
|
||||
var title: String { self == .playback ? "Wiedergabekarte" : "Sonderkarte" }
|
||||
}
|
||||
|
||||
enum CardRoute: String, CaseIterable, Identifiable {
|
||||
case tonuino, acr122u
|
||||
var id: String { rawValue }
|
||||
var title: String { self == .tonuino ? "Über TonUINO" : "ACR122U Reader" }
|
||||
}
|
||||
Reference in new issue
Block a user