Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Promptly.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "We use bluetooth connectivity to connect to Promptly Clicker devices for script line control.";
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "This app connects to BLE devices for remote control functionality.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "We need access to your local network to serve your show script for other members of your crew.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
Expand Down Expand Up @@ -339,6 +340,7 @@
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "We use bluetooth connectivity to connect to Promptly Clicker devices for script line control.";
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "This app connects to BLE devices for remote control functionality.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "We need access to your local network to serve your show script for other members of your crew.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
Expand Down
Binary file not shown.
32 changes: 32 additions & 0 deletions Promptly/Contants.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// Contants.swift
// Promptly
//
// Created by Sasha Bagrov on 01/10/2025.
//

import Foundation

struct Constants {
private static let mqttIPKey = "mqttIP"
private static let mqttPortKey = "mqttPort"

static var mqttIP: String {
get {
UserDefaults.standard.string(forKey: mqttIPKey) ?? "192.168.1.1"
}
set {
UserDefaults.standard.set(newValue, forKey: mqttIPKey)
}
}

static var mqttPort: Int {
get {
let port = UserDefaults.standard.integer(forKey: mqttPortKey)
return port == 0 ? 1883 : port
}
set {
UserDefaults.standard.set(newValue, forKey: mqttPortKey)
}
}
}
74 changes: 74 additions & 0 deletions Promptly/JSON Server/JSONServer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// JSONServer.swift
// Promptly
//
// Created by Sasha Bagrov on 01/10/2025.
//

import Network
import Foundation
import Combine

class JSONServer: ObservableObject {
let port: NWEndpoint.Port
let listener: NWListener
var connections: [NWConnection] = []

init(port: UInt16) {
self.port = NWEndpoint.Port(rawValue: port)!
self.listener = try! NWListener(using: .tcp, on: self.port)
}

func start(dataToServe: [String: Any]) {
listener.stateUpdateHandler = { state in
switch state {
case .ready:
print("Server ready on port \(self.port)")
case .failed(let error):
print("Server failed: \(error)")
default:
break
}
}

listener.newConnectionHandler = { [weak self] connection in
self?.handleConnection(connection, data: dataToServe)
}

listener.start(queue: .main)
}

func stop() {
listener.cancel()
connections.forEach { $0.cancel() }
}

private func handleConnection(_ connection: NWConnection, data: [String: Any]) {
connections.append(connection)

connection.start(queue: .main)

connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, context, isComplete, error in
if let jsonData = try? JSONSerialization.data(withJSONObject: data, options: .prettyPrinted) {
let response = self?.buildHTTPResponse(body: jsonData)
connection.send(content: response, completion: .contentProcessed { _ in
connection.cancel()
})
}
}
}

private func buildHTTPResponse(body: Data) -> Data {
let header = """
HTTP/1.1 200 OK\r
Content-Type: application/json\r
Content-Length: \(body.count)\r
Connection: close\r
\r

"""
var response = header.data(using: .utf8)!
response.append(body)
return response
}
}
117 changes: 115 additions & 2 deletions Promptly/MQTT/MQTTManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ class MQTTManager: ObservableObject {

private var messageHandlers: [String: (String) -> Void] = [:]
private var cancellables = Set<AnyCancellable>()
private var updateTimers: [String: DispatchSourceTimer] = [:]
private var deviceHeartbeatTimer: DispatchSourceTimer?
private let timerQueue = DispatchQueue(label: "com.promptly.mqtt.timers", qos: .background)

func connect(to host: String, port: Int = 1883) {
client = MQTTClient(
Expand Down Expand Up @@ -58,6 +61,10 @@ class MQTTManager: ObservableObject {
}

func disconnect() {
updateTimers.values.forEach { $0.cancel() }
updateTimers.removeAll()
deviceHeartbeatTimer?.cancel()
deviceHeartbeatTimer = nil
client?.disconnect()
}

Expand All @@ -76,10 +83,13 @@ class MQTTManager: ObservableObject {
}

func subscribeToShowChanges(onChange: @escaping (String, String) -> Void) {
let showsPattern = "shows/+"
let showsPattern = "shows/#"

client?.messagePublisher
.filter { $0.topic.hasPrefix("shows/") }
.filter { message in
let components = message.topic.split(separator: "/")
return components.count == 2 && components.first == "shows"
}
.sink { message in
let topic = message.topic
let messageString = message.payload.string ?? ""
Expand All @@ -94,4 +104,107 @@ class MQTTManager: ObservableObject {

client?.subscribe(to: showsPattern)
}

func getShow(id: String, completion: @escaping (String?, String?, String?, String?, String?) -> Void) {
let title = receivedMessages["shows/\(id)/title"]
let location = receivedMessages["shows/\(id)/location"]
let scriptName = receivedMessages["shows/\(id)/scriptName"]
let status = receivedMessages["shows/\(id)/status"]
let dsmNetworkIP = receivedMessages["shows/\(id)/dsmNetworkIP"]

completion(title, location, scriptName, status, dsmNetworkIP)
}

func sendOutShow(id: String, title: String?, location: String?, scriptName: String?, status: PerformanceState?, dsmNetworkIP: String?) {
if let title = title {
sendData(to: "shows/\(id)/title", message: title)
}
if let location = location {
sendData(to: "shows/\(id)/location", message: location)
}
if let scriptName = scriptName {
sendData(to: "shows/\(id)/scriptName", message: scriptName)
}
if let dsmNetworkIP = dsmNetworkIP {
sendData(to: "shows/\(id)/dsmNetworkIP", message: dsmNetworkIP)
}
if let status = status {
sendData(to: "shows/\(id)/status", message: status.displayName)
}

sendData(to: "shows/\(id)/line", message: "0")
sendData(to: "shows/\(id)/calledCues", message: "[]")
sendData(to: "shows/\(id)/timeCalls", message: "")

sendData(to: "shows/\(id)", message: "updated")

startPeriodicUpdate(for: id)
}

func removeShow(id: String) {
stopPeriodicUpdate(for: id)

let topics = [
"shows/\(id)/title",
"shows/\(id)/location",
"shows/\(id)/scriptName",
"shows/\(id)/dsmNetworkIP",
"shows/\(id)/status",
"shows/\(id)/line",
"shows/\(id)/calledCues",
"shows/\(id)/timeCalls",
"shows/\(id)"
]

topics.forEach { topic in
client?.publish("", to: topic, qos: .atLeastOnce, retain: true)
}
}

func broadcastDevice(showId: String, deviceUUID: UUID) {
let topic = "shows/\(showId)/devices/\(deviceUUID.uuidString)"

deviceHeartbeatTimer?.cancel()

let timer = DispatchSource.makeTimerSource(queue: timerQueue)
timer.schedule(deadline: .now(), repeating: 30)
timer.setEventHandler { [weak self] in
self?.sendData(to: topic, message: "heartbeat")
}
timer.resume()

deviceHeartbeatTimer = timer
}

func removeDevice(showId: String, deviceUUID: UUID) {
let topic = "shows/\(showId)/devices/\(deviceUUID.uuidString)"

deviceHeartbeatTimer?.cancel()
deviceHeartbeatTimer = nil

sendData(to: topic, message: "offline")
}

func stopBroadcastingDevice() {
deviceHeartbeatTimer?.cancel()
deviceHeartbeatTimer = nil
}

private func startPeriodicUpdate(for id: String) {
updateTimers[id]?.cancel()

let timer = DispatchSource.makeTimerSource(queue: timerQueue)
timer.schedule(deadline: .now() + 60, repeating: 60)
timer.setEventHandler { [weak self] in
self?.sendData(to: "shows/\(id)", message: "updated")
}
timer.resume()

updateTimers[id] = timer
}

func stopPeriodicUpdate(for id: String) {
updateTimers[id]?.cancel()
updateTimers.removeValue(forKey: id)
}
}
34 changes: 34 additions & 0 deletions Promptly/Models/Performance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,40 @@ enum PerformanceState: Codable, Equatable {
case stopped
}

extension PerformanceState {
init(displayName: String) {
if displayName == "Pre-Show" {
self = .preShow
} else if displayName == "House Open" {
self = .houseOpen
} else if displayName == "Stage Clear" {
self = .clearance
} else if displayName.hasPrefix("Act ") && displayName.hasSuffix(" Running") {
let numberString = displayName
.dropFirst(4)
.dropLast(8)
if let actNumber = Int(numberString) {
self = .inProgress(actNumber: actNumber)
} else {
self = .preShow
}
} else if displayName.hasPrefix("Interval ") {
let numberString = displayName.dropFirst(9)
if let intervalNumber = Int(numberString) {
self = .interval(intervalNumber: intervalNumber)
} else {
self = .preShow
}
} else if displayName == "Show Complete" {
self = .completed
} else if displayName == "Show Stopped" {
self = .stopped
} else {
self = .preShow
}
}
}

@Model
class CallSettings {
var halfTime: TimeInterval = 35 * 60
Expand Down
Loading
Loading