Hello,
I am currently writing an application in Swift which has no GUI. It is designed to run on a Raspberry Pi and control some real-world hardware via an electrical relay. It will be installed in a handlful of locations.
The application has certain global properties which I would like to be able to configure on a location-by-location basis and from time-to-time adjust without recompiling the application. e.g. Thresholds for logging levels.
I would be interested in learning what the best, and most 'Swifty' way to accomplish this would be. I have come-up with a design, but would like to know if there's a better way. (I'm sure that there is...)
My design:
Initially I had a very naĂŻve system where I hard-coded the properties / variables inline as I wrote the code. I think that this is known as 'magic numbers'. This is obviously bad.
I then refined this to place all these properties into a separate file (a struct, named 'AppConfiguration
'), full of property declarations. That way I could replace the magic numbers with a global property named 'config' let config = AppConfiguration()
and where I needed to refer to one of the properties using config.somePropertyName
This was better, but the obvious issue here was that the properties were all hard-coded at compile-time and immutable. I was just moving multiple problems to one single location.
Then I thought that it would be good to make the confg struct Codable. That way I could read and write it to the filesystem as JSON, swap-out config files and create a default config file.
That sounded fine, but I quickly realised that some of the properties I wanted to store in the config struct were of Types that I didn't control and did not conform to Codable (e.g. Logging Levels from the Puppy package.)
My Fix
At this point I got into problem-solving/hacking mode and decided that almost all of the types in my Config struct were codable, but the ones that weren't were Enums. I realised that I could ignore the enum values when (en/)coding (via codingKeys) and have stringly-typed (a Swift heresy) proxy properties which would then set the correct enum values via the RawValue
: initialiser.
Code:
Below is the code for my AppConfig Struct. What do you think of it and how could it be implemented better?
Thanks.
// AppConfiguration.swift
import Foundation
import Puppy
import SwiftyGPIO
// Note: Logging is not available yet at this point of the app lifecycle…
public struct AppConfiguration: Codable {
// Ignore the enums that aren't codable
enum CodingKeys: CodingKey {
case logFolderPath
case configFolderPath
case beaconExpiryDuration
case consoleLoggingLevelAsInt
case piHardwareVersionAsString
case relayControlSignalGPIOPinAsString
}
// Stringly typed alternatives to the enum properties
// MARK: - Stringly typed enum properties -
var consoleLoggingLevelAsInt: UInt8 = 4 // Info
var piHardwareVersionAsString = "RaspberryPi3"
var relayControlSignalGPIOPinAsString = "P18"
// MARK: - Logging -
// From Puppy Package
var consoleLoggingLevel: LogLevel = .info // Info level and more severe
var logFolderPath: String = "./Logs" // in the same path as the executable. Should really be /var/log/GateControl
// MARK: - Config Files location -
var configFolderPath: String = "/etc/GateControl" // Standard UNIX path (but with wrong capitalisiation…)
// MARK: - Hardware Choices -
// From GPIO Package
var piHardwareVersion: SupportedBoard = .RaspberryPi3 // Assuming Pi 3 (Pi 4 shares the same numbering)
var relayControlSignalGPIOPin: GPIOName = .P18 // GPIO 18, base board number 12 (6th down on the right-hand-side column)
// (when the USB ports are at the bottom of the device)
// (and the pins are on the right side of the board.)
// MARK: - Beacon Scanner -
var beaconExpiryDuration: Double = 30.0 // Number of Seconds after which we assume that the beacon has left.
// MARK: - Functions -
/// Prints the config as JSON - Useful for copying to a file.
func spitOutJSON() {
do {
let encodedData = try JSONEncoder().encode(self)
let jsonString = String(data: encodedData, encoding: .utf8)
print(jsonString ?? "Could not encode JSON")
} catch {
print(error)
}
}
mutating func loadConfigFromFilesystem() {
var importedConfig: AppConfiguration
let importPath = configFolderPath + "/config.json"
do {
if let importedJsonData = try String(contentsOfFile: importPath).data(using: .utf8) {
importedConfig = try JSONDecoder().decode(AppConfiguration.self, from: importedJsonData)
} else {
print("AppConfig: could not convert string to JSON \(importPath)")
return
}
} catch {
print("AppConfig: Could not load config from JSON - \(importPath) - \(error)")
return
}
importedConfig.convertStringPropertiesToEnumProperties()
// We should now have a valid config instance.
// Swap ourselves for the imported instance.
self = importedConfig
}
mutating func convertStringPropertiesToEnumProperties() {
/*
public enum LogLevel: UInt8, Sendable {
case trace = 1
case verbose = 2
case debug = 3
case info = 4
case notice = 5
case warning = 6
case error = 7
case critical = 8
}
*/
consoleLoggingLevel = LogLevel(rawValue: consoleLoggingLevelAsInt) ?? .info
piHardwareVersion = SupportedBoard(rawValue: piHardwareVersionAsString) ?? .RaspberryPi3
relayControlSignalGPIOPin = GPIOName(rawValue: relayControlSignalGPIOPinAsString) ?? .P18
}
}