diff --git a/EventBus.md b/EventBus.md deleted file mode 100644 index aa98d7c..0000000 --- a/EventBus.md +++ /dev/null @@ -1,22 +0,0 @@ -# Simple Event Bus - -A _very_ simple, quasi-type driven pub-sub event bus. This uses the built-in `Publisher`/`Subscriber` classes from the basic Java implementations. - -Currently, this uses the default implementations in terms of threads and buffers. Each "topic" is created with an individual `Producer`. - -## Classes - -![](EventBus.png) - -## Usage - -THe first thing to note is that there is no real type safety availble: each subscriber must be able to handle the messages put on the subscribed topic, so some cooperation between the producer and all subscribers are necessary. - -`KobotsEvent` and `KobotsAction` are intended to provide separation between _requesting_ "actions" and "events" _occurring_ in the system. Examples: - -- the [`SequenceRequest`](src/main/kotlin/crackers/kobots/execution/Messages.kt) is a `KobotsAction` for requesting physical changes (movement). -- a sensor polling in a separate thread can send out measurement or alert messages via `KobotsEvent` implementations - -Use either of the `publitshToTopic` methods to send one or more requests or events. - -Each `KobotSubscriber` is wrapped in an appropriate Java `Subscriber` and will receive as many messages up to the number specified. The default is one at a time. diff --git a/EventBus.png b/EventBus.png deleted file mode 100644 index 3bb2621..0000000 Binary files a/EventBus.png and /dev/null differ diff --git a/EventBus.puml b/EventBus.puml deleted file mode 100644 index 4590eb9..0000000 --- a/EventBus.puml +++ /dev/null @@ -1,32 +0,0 @@ -@startuml -'https://plantuml.com/class-diagram - -interface KobotsMessage -interface KobotsEvent -interface KobotsAction { - +interruptable: Boolean -} - -KobotsEvent <|-- KobotsMessage -KobotsAction <|-- KobotsMessage - -interface KobotsSubscriber { - +receive(msg: M): void -} -KobotsSubscriber <-- KobotsMessage - -metaclass EventBus { - + joinTopic(topic:String, listener:KobotsSubscriber, batchSize:Long = 1): void - + leaveTopic(topic:String, listener:KobotsSubscriber): void - + publishToTopic(topic:String, vararg items:M): void - + publishToTopic(topic:String, items:Collection): void -} - -EventBus <-- KobotsMessage -EventBus <-- KobotsSubscriber - -class EmergencyStop { - +interruptable = false -} -EmergencyStop <|-- KobotsAction -@enduml diff --git a/Movements.md b/Movements.md index 7b098c2..c6922e4 100644 --- a/Movements.md +++ b/Movements.md @@ -65,9 +65,9 @@ Only a single actuator is currently defined: ## Sequence Executor -The [SequenceExecutor](src/main/kotlin/crackers/kobots/parts/SequenceExecutor.kt) provides one means of executing sequences and actions. It is primarily intended to be used with the [Event Bus](EventBus.md). +The [SequenceExecutor](src/main/kotlin/crackers/kobots/parts/SequenceExecutor.kt) provides one means of executing sequences and actions. -This class provides a means to execute sequences in a _background thread_. The actions are executed sequentially, invoking the _stopCheck_ functions prior to moving each component, as well as providing a mans of signalling an _immediate_ stop. The actions are executed in a way to provide pauses between invocations: this allows the physical systems to move incrementally, reducing stress (this speed can obviously be modified). The actual spped a step can execute will be limited by the hardware I/O. +This class provides functions to execute sequences in a _background thread_. The actions are executed sequentially, invoking the _stopCheck_ functions prior to moving each component, as well as providing a mans of signalling an _immediate_ stop. The actions are executed in a way to provide pauses between invocations: this allows the physical systems to move incrementally, reducing stress (this speed can obviously be modified). The actual spped a step can execute will be limited by the hardware I/O. Implementations of this class **MUST** handle any device or I/O contention -- this class does not provide any "locking" of devices. diff --git a/README.md b/README.md index 1542702..2cc1c30 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,15 @@ Contains basic application construction elements that are being used in my vario - Generic application "junk" - Abstractions to orchestrate physical device interactions - Includes movements and the human stuff -- A simplified event-bus for in-process communication -- Wrappers around MQTT for external communications +- Wrappers around MQTT/HomeAssistant for external communications There are three main sections. - [Actuators and Movements](Movements.md) -- [Event Bus](EventBus.md) - [Home Assistant](HomeAssistant.md) +:bangbang: The **EventBus** was removed due to non-usage: since most actions are now either constrainted to the `SequenceManager` in the movements or via HomeAssistant. + Javadocs are published at the [GitHub Pages](https://eagrahamjr.github.io/kobots-parts/) for this project. ## Acknowledgements diff --git a/src/main/kotlin/crackers/kobots/app/AppCommon.kt b/src/main/kotlin/crackers/kobots/app/AppCommon.kt index 6bf2289..c6504fe 100644 --- a/src/main/kotlin/crackers/kobots/app/AppCommon.kt +++ b/src/main/kotlin/crackers/kobots/app/AppCommon.kt @@ -19,8 +19,6 @@ package crackers.kobots.app import com.typesafe.config.ConfigFactory import crackers.hassk.HAssKClient import crackers.kobots.mqtt.KobotsMQTT -import crackers.kobots.parts.app.KobotsEvent -import crackers.kobots.parts.app.publishToTopic import org.slf4j.LoggerFactory import java.net.InetAddress import java.util.concurrent.CountDownLatch @@ -114,16 +112,6 @@ object AppCommon { KobotsMQTT(InetAddress.getLocalHost().hostName, applicationConfig.getString("mqtt.broker")) } - /** - * Generic topic and event for sleep/wake events on the internal event bus. - */ - const val SLEEP_TOPIC = "System.Sleep" - - class SleepEvent(val sleep: Boolean) : KobotsEvent - - fun goToSleep() = publishToTopic(SLEEP_TOPIC, SleepEvent(true)) - fun wakey() = publishToTopic(SLEEP_TOPIC, SleepEvent(false)) - fun ignoreErrors(executionBlock: () -> F?, logIt: Boolean = false): F? = try { executionBlock() } catch (t: Throwable) { diff --git a/src/main/kotlin/crackers/kobots/graphics/animation/EightBall.kt b/src/main/kotlin/crackers/kobots/graphics/animation/EightBall.kt new file mode 100644 index 0000000..eace679 --- /dev/null +++ b/src/main/kotlin/crackers/kobots/graphics/animation/EightBall.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2022-2024 by E. A. Graham, Jr. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package crackers.kobots.graphics.animation + +import crackers.kobots.graphics.center +import crackers.kobots.graphics.loadImage +import crackers.kobots.graphics.middle +import java.awt.Color +import java.awt.Font +import java.awt.Graphics2D +import kotlin.math.roundToInt + +/** + * 8-ball for small displays. Default size is for a "large" OLED (128x128). + * + * An image of the 8-ball is scaled to fit and the [next] function will print the fortune. Note that both will clear + * the designated region + */ +class EightBall( + private val graphics: Graphics2D, + private val x: Int = 0, + private val y: Int = 0, + private val width: Int = 128, + private val height: Int = 128 +) { + private val eightBallFont: Font + + // TODO use multi-line responses, line-breaks, and a bigger font! + private val eightBallList = listOf( + "It is certain", "Reply hazy, try again", "Don’t count on it", "Don’t count on it", + "Ask again later", "My reply is no", "Without a doubt", "Better not tell you now", + "My sources say no", "Yes definitely", "Cannot predict now", "Outlook not so good", + "You may rely on it", "Concentrate and ask again", "Very doubtful", + "As I see it, yes", "Most likely", "Outlook good", "Yes", "Signs point to yes" + ) + .also { theList -> + var fitThis = "" + theList.forEach { if (it.length > fitThis.length) fitThis = it } + var fontSize = 18.0f + var font = Font(Font.SERIF, Font.PLAIN, fontSize.roundToInt()) + while (font.size > 0f) { + if (graphics.getFontMetrics(font).stringWidth(fitThis) < width) break + fontSize = fontSize - .5f + font = font.deriveFont(fontSize) + } + if (font.size < 1f) throw IllegalStateException("Cannot get font size") + eightBallFont = font + } + + private val eightBallImage by lazy { loadImage("/8-ball.png") } + private val imageX = (width - height) / 2 + + fun image() = with(graphics) { + clearRect(x, y, width, height) + drawImage(eightBallImage, imageX, 0, height, height, null) + } + + fun next() = with(graphics) { + color = Color.WHITE + background = Color.BLACK + font = eightBallFont + + clearRect(y, x, width, height) + val sayThis = eightBallList.random() + val textX = fontMetrics.center(sayThis, width) + val textY = fontMetrics.middle(height) + drawString(sayThis, textX + x, textY + y) + } +} diff --git a/src/main/kotlin/crackers/kobots/mqtt/KobotsMQTT.kt b/src/main/kotlin/crackers/kobots/mqtt/KobotsMQTT.kt index 7cd966a..aeaa520 100644 --- a/src/main/kotlin/crackers/kobots/mqtt/KobotsMQTT.kt +++ b/src/main/kotlin/crackers/kobots/mqtt/KobotsMQTT.kt @@ -15,8 +15,6 @@ */ package crackers.kobots.mqtt -import crackers.kobots.parts.app.EmergencyStop -import crackers.kobots.parts.app.STOP_NOW import org.eclipse.paho.mqttv5.client.* import org.eclipse.paho.mqttv5.common.MqttException import org.eclipse.paho.mqttv5.common.MqttMessage @@ -31,7 +29,6 @@ import java.util.concurrent.Executors import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean -import kotlin.system.exitProcess /** * MQTT wrapper for Kobots. @@ -282,29 +279,6 @@ class KobotsMQTT(private val clientName: String, broker: String) : AutoCloseable operator fun set(topic: String, payload: String) = publish(topic, payload.toByteArray()) operator fun set(topic: String, payload: ByteArray) = publish(topic, payload) - /** - * Set up and allow [EmergencyStop] to forcibly terminate the application. This is intended to allow for a single - * "stop" to kill everything listening. - * - * **NOTE** This uses a separate topic from everything else and should be "private" to this function. - */ - fun allowEmergencyStop() { - subscribeJSON(KOBOTS_STOP) { event -> - if (event.optString("name") == STOP_NOW) { - logger.error("Emergency stop received") - close() - exitProcess(3) - } - } - } - - /** - * Publish an [EmergencyStop] event. This should kill everything listening. See [allowEmergencyStop]. - */ - fun emergencyStop() { - publish(KOBOTS_STOP, JSONObject(EmergencyStop())) - } - override fun close() { mqttClient.close() } @@ -324,10 +298,5 @@ class KobotsMQTT(private val clientName: String, broker: String) : AutoCloseable * Sequence events are published to this topic. */ const val KOBOTS_EVENTS = "kobots/events" - - /** - * Emergency stops on a separate topic. - */ - private const val KOBOTS_STOP = "kobots/stop" } } diff --git a/src/main/kotlin/crackers/kobots/mqtt/homeassistant/KobotLightController.kt b/src/main/kotlin/crackers/kobots/mqtt/homeassistant/KobotLightController.kt index a0c43b1..f7a27d5 100644 --- a/src/main/kotlin/crackers/kobots/mqtt/homeassistant/KobotLightController.kt +++ b/src/main/kotlin/crackers/kobots/mqtt/homeassistant/KobotLightController.kt @@ -17,30 +17,47 @@ package crackers.kobots.mqtt.homeassistant import com.diozero.api.PwmOutputDevice -import crackers.kobots.devices.set -import java.util.concurrent.CompletableFuture import kotlin.math.roundToInt /** - * Interface for controlling a light. This is used by [KobotLight] and [KobotLightStrip] to abstract the actual - * hardware implementation. + * Interface for controlling a light. This is used by [KobotLight] to abstract the actual hardware implementation. + * + * The [exec], [flash], and [transition] functions are specifically called out since they will _usually_ involve some + * background tasking to work with this system. */ interface LightController { /** - * Set the state of the light. Node "0" represents either a single LED or a full LED strand. Non-zero indices are - * to address each LED individually, offset by 1. + * Set the state of the light. */ infix fun set(command: LightCommand) /** - * Execute an effect in some sort of completable manner. Note this **must** be cancellable since commands can be - * compounded. + * Execute an effect in some sort of completable manner. Note this **must** be cancellable by any subsequent + * commands. + */ + infix fun exec(effect: String) { + // does nothing + } + + /** + * Flash the light (on/off) using this period (seconds). Continues flashing until another command is received. + * + * **NOTE** HA is only currently sending either 10 or 2 to signal fast/slow. */ - infix fun exec(effect: String): CompletableFuture + infix fun flash(flash: Int) { + // does nothing + } /** - * Get the state of the light. Node "0" represents either a single LED or a full LED strand. Non-zero indices are - * to address each LED individually, offset by 1. + * Transition from the current state to a new state. Because this _can_ include color and brightness changes, the + * whole parsed command is necessary to complete this function. + */ + infix fun transition(command: LightCommand) { + // does nothing + } + + /** + * Get the state of the light. */ fun current(): LightState @@ -50,29 +67,20 @@ interface LightController { } /** - * Turns it on and off, adjust brightness. Supports a minimal set of effects. + * Turns it on and off, adjust brightness. * - * TODO the effect should include (`n` is a duration) - * - * * `blink n` on/off; duration is how long the light stays in that state - * * `pulse n` gently fade in/out; duration is for a full cycle7 + * TODO support a minimal set of effects? should support fade-in/out as HA settings? */ class BasicLightController(val device: PwmOutputDevice) : LightController { private var currentEffect: String? = null override val lightEffects: List? = null override fun set(command: LightCommand) = with(command) { - if (brightness != null) { - device.setValue(brightness / 100f) - } else { - device.set(state) - } - } - - override fun exec(effect: String): CompletableFuture { - currentEffect = effect - return CompletableFuture.runAsync { - TODO("No effects yet") + // off over-rides everything + device.value = when { + !state -> 0f + brightness != null -> brightness / 100f + else -> 1f } } @@ -81,5 +89,6 @@ class BasicLightController(val device: PwmOutputDevice) : LightController { brightness = (device.value * 100f).roundToInt(), effect = currentEffect ) + override val controllerIcon = "mdi:lamp" } diff --git a/src/main/kotlin/crackers/kobots/mqtt/homeassistant/KobotLightDevices.kt b/src/main/kotlin/crackers/kobots/mqtt/homeassistant/KobotLightDevices.kt index c00fa4b..47a71bc 100644 --- a/src/main/kotlin/crackers/kobots/mqtt/homeassistant/KobotLightDevices.kt +++ b/src/main/kotlin/crackers/kobots/mqtt/homeassistant/KobotLightDevices.kt @@ -23,8 +23,6 @@ import crackers.kobots.parts.toMireds import org.json.JSONObject import java.awt.Color import java.awt.Color.BLACK -import java.util.concurrent.CompletableFuture -import java.util.concurrent.atomic.AtomicReference import kotlin.math.roundToInt /* @@ -94,12 +92,17 @@ data class LightCommand( val state: Boolean, val brightness: Int?, val color: Color?, - val effect: String? + val effect: String?, + val flash: Int, + val transition: Float ) { companion object { fun JSONObject.commandFrom(): LightCommand = with(this) { var state = optString("state", null)?.let { it == "ON" } ?: false + // extract the effect: if no effect, see if there's transition or flash val effect = optString("effect", null) + val flash = if (effect == null) optInt("flash", 0) else 0 + val trans = if (effect == null && flash != 0) optFloat("transition", 0f) else 0f // brightness is 0-255, so translate to 0-100 val brightness = takeIf { has("brightness") }?.let { getInt("brightness") * 100f / 255f }?.roundToInt() @@ -114,7 +117,7 @@ data class LightCommand( // set state regardless if (brightness != null || color != null) state = true - LightCommand(state, brightness, color, effect) + LightCommand(state, brightness, color, effect, flash, trans) } } } @@ -137,30 +140,23 @@ open class KobotLight( override fun discovery() = super.discovery().apply { put("brightness", true) -// controller.lightEffects?.let { -// put("effect", true) -// put("effect_list", it.sorted()) -// } + controller.lightEffects?.let { + put("effect", true) + put("effect_list", it.sorted()) + } } override fun currentState() = controller.current().json().toString() - private val effectFuture = AtomicReference>() - private var theFuture: CompletableFuture? // for pretty - get() = effectFuture.get() - set(v) { - effectFuture.set(v) - } - - override fun handleCommand(payload: String) = with(JSONObject(payload).commandFrom()) { - // stop any running effect - theFuture?.cancel(true) - - if (effect != null) { - TODO("Effects are not enabled due to thread management") -// theFuture = controller.lightEffects?.takeIf { effect in it }?.let { controller exec effect } - } else { - controller set this + override fun handleCommand(payload: String) { + val cmd = JSONObject(payload).commandFrom() + // split out specialized support mechanisms for background managements + when { + !cmd.state -> controller set cmd // off over-rides anything + cmd.effect != null -> controller exec cmd.effect + cmd.flash > 0 -> controller flash cmd.flash + cmd.transition > 0f -> controller transition cmd + else -> controller set cmd } } } diff --git a/src/main/kotlin/crackers/kobots/mqtt/homeassistant/PimoroniShimController.kt b/src/main/kotlin/crackers/kobots/mqtt/homeassistant/PimoroniShimController.kt index 6eeb9c2..8774836 100644 --- a/src/main/kotlin/crackers/kobots/mqtt/homeassistant/PimoroniShimController.kt +++ b/src/main/kotlin/crackers/kobots/mqtt/homeassistant/PimoroniShimController.kt @@ -19,7 +19,6 @@ package crackers.kobots.mqtt.homeassistant import crackers.kobots.devices.lighting.PimoroniLEDShim import crackers.kobots.parts.scale import java.awt.Color -import java.util.concurrent.CompletableFuture import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference @@ -63,7 +62,7 @@ class PimoroniShimController(private val device: PimoroniLEDShim) : LightControl } } - override fun exec(effect: String): CompletableFuture { + override fun exec(effect: String) { TODO("Not yet implemented") } diff --git a/src/main/kotlin/crackers/kobots/mqtt/homeassistant/PixelBufController.kt b/src/main/kotlin/crackers/kobots/mqtt/homeassistant/PixelBufController.kt index 86be42e..ada6c5c 100644 --- a/src/main/kotlin/crackers/kobots/mqtt/homeassistant/PixelBufController.kt +++ b/src/main/kotlin/crackers/kobots/mqtt/homeassistant/PixelBufController.kt @@ -20,7 +20,6 @@ import crackers.kobots.devices.lighting.PixelBuf import crackers.kobots.devices.lighting.WS2811 import org.slf4j.LoggerFactory import java.awt.Color -import java.util.concurrent.CompletableFuture import java.util.concurrent.atomic.AtomicReference import kotlin.math.roundToInt @@ -68,16 +67,6 @@ class SinglePixelLightController( lastColor = WS2811.PixelColor(color, brightness = cb) theStrand[index] = lastColor } - - override fun exec(effect: String) = CompletableFuture.runAsync { - try { - effects?.get(effect)?.invoke(theStrand, index)?.also { currentEffect.set(effect) } - } catch (t: Throwable) { - logger.error("Error executing effect $effect", t) - } - }.whenComplete { _, _ -> - currentEffect.set(null) - } } /** @@ -126,14 +115,4 @@ class PixelBufController( effect = currentEffect.get() ) } - - override fun exec(effect: String) = CompletableFuture.runAsync { - try { - effects?.get(effect)?.invoke(theStrand)?.also { currentEffect.set(effect) } - } catch (t: Throwable) { - logger.error("Error executing effect $effect", t) - } - }.whenComplete { _, _ -> - currentEffect.set(null) - } } diff --git a/src/main/kotlin/crackers/kobots/parts/app/EventBus.kt b/src/main/kotlin/crackers/kobots/parts/app/EventBus.kt deleted file mode 100644 index a5784f8..0000000 --- a/src/main/kotlin/crackers/kobots/parts/app/EventBus.kt +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2022-2024 by E. A. Graham, Jr. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package crackers.kobots.parts.app - -import org.slf4j.LoggerFactory -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Flow -import java.util.concurrent.SubmissionPublisher - -interface KobotsMessage -interface KobotsAction : KobotsMessage - -interface KobotsEvent : KobotsMessage - -private val eventBusMap = ConcurrentHashMap>() - -/** - * Receives an item from a topic. See [joinTopic] - */ -fun interface KobotsSubscriber { - fun receive(msg: T) -} - -/** - * Wraps the subscriber in all the extra stuff necessary. Requests [batchSize] items. - */ -private class KobotsSubscriberDecorator(val listener: KobotsSubscriber, val batchSize: Long) : - Flow.Subscriber { - - private val logger by lazy { LoggerFactory.getLogger("EventSubscriber") } - - private lateinit var mySub: Flow.Subscription - override fun onSubscribe(subscription: Flow.Subscription) { - mySub = subscription - mySub.request(batchSize) - } - - override fun onNext(item: T) { - try { - listener.receive(item) - } catch (t: Throwable) { - logger.error("Error in bus -- unable to receive message", t) - } - mySub.request(batchSize) - } - - override fun onError(throwable: Throwable?) { - logger.error("Error in bus", throwable) - } - - override fun onComplete() { -// TODO("Not yet implemented") - } -} - -/** - * Get items (default 1 at a time) asynchronously. - */ -fun joinTopic(topic: String, listener: KobotsSubscriber, batchSize: Long = 1) { - getPublisher(topic).subscribe(KobotsSubscriberDecorator(listener, batchSize)) -} - -/** - * Get items 1 at a time. - */ -fun joinTopic(topic: String, listener: KobotsSubscriber) { - joinTopic(topic, listener, 1) -} - -/** - * Stop getting items. - */ -@Suppress("UNCHECKED_CAST") -fun leaveTopic(topic: String, listener: KobotsSubscriber) { - getPublisher(topic).subscribers.removeIf { (it as KobotsSubscriberDecorator).listener == listener } -} - -/** - * Publish one or more [items] to a [topic]. - */ -fun publishToTopic(topic: String, vararg items: T) { - publishToTopic(topic, items.toList()) -} - -/** - * Publish a collection of [items] to a [topic] - */ -fun publishToTopic(topic: String, items: Collection) { - val publisher = getPublisher(topic) - items.forEach { item -> publisher.submit(item) } -} - -@Suppress("UNCHECKED_CAST") -private fun getPublisher(topic: String) = - eventBusMap.computeIfAbsent(topic) { SubmissionPublisher() } as SubmissionPublisher - -// specific messages ================================================================================================== -const val STOP_NOW = "Emergency Stop" - -class EmergencyStop : KobotsAction { - val name = STOP_NOW -} - -val allStop = EmergencyStop() diff --git a/src/main/kotlin/crackers/kobots/parts/movement/ActionSequence.kt b/src/main/kotlin/crackers/kobots/parts/movement/ActionSequence.kt index 18dbd77..153e2cf 100644 --- a/src/main/kotlin/crackers/kobots/parts/movement/ActionSequence.kt +++ b/src/main/kotlin/crackers/kobots/parts/movement/ActionSequence.kt @@ -16,6 +16,7 @@ package crackers.kobots.parts.movement +import kotlin.time.Duration import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -50,30 +51,43 @@ class LinearMovementBuilder(linear: LinearActuator) : MovementBuilder Boolean) : Movement { - override val stopCheck = execution -} - -private class SimpleActuator : Actuator { +// only need one of these +private val SIMPLE = object : Actuator { // stop check has the code` - override fun move(movement: SimpleMovement) = false + override fun move(movement: Movement) = false + override fun current() = 0 } -// only need one of these -private val SIMPLE = SimpleActuator() - private class ExecutableMovementBuilder(private val function: () -> Boolean) : - MovementBuilder(SimpleActuator()) { - override fun makeMovement(): SimpleMovement { - return SimpleMovement(function) + MovementBuilder>(SIMPLE) { + override fun makeMovement(): Movement { + return object : Movement { + override val stopCheck = function + } } } +/** + * How long each step should take to execute **at a minimum**. + */ interface ActionSpeed { val millis: Long - fun duration() = millis.toDuration(DurationUnit.MILLISECONDS) + fun duration(): Duration = millis.toDuration(DurationUnit.MILLISECONDS) } +/** + * Makes an [ActionSpeed] in millis from one. + */ +fun Long.toSpeed(): ActionSpeed { + val v = this + return object : ActionSpeed { + override val millis = v + } +} + +/** + * "Default" speeds based on observation. + */ enum class DefaultActionSpeed(override val millis: Long) : ActionSpeed { VERY_SLOW(100), SLOW(50), NORMAL(10), FAST(5), VERY_FAST(2) } @@ -204,6 +218,14 @@ class ActionSequence { it.steps += otherSequence.steps } + /** + * Append (add) another action builder to the list of steps. Does not return anything but does modify the + * internal list. (Yes, this is contrary to most operators, but it's nicer for DSL.) + */ + operator fun plus(actionBuilder: ActionBuilder) { + this.steps += actionBuilder + } + /** * Add the other sequence's steps to this one. */ diff --git a/src/main/kotlin/crackers/kobots/parts/movement/Linear.kt b/src/main/kotlin/crackers/kobots/parts/movement/Linear.kt index 9aa56ec..3dee6c9 100644 --- a/src/main/kotlin/crackers/kobots/parts/movement/Linear.kt +++ b/src/main/kotlin/crackers/kobots/parts/movement/Linear.kt @@ -44,7 +44,7 @@ interface LinearActuator : Actuator { /** * Returns the current position as a percentage of the total range of motion. */ - fun current(): Int + override fun current(): Int /** * Operator short-cut for [extendTo]. @@ -105,9 +105,6 @@ open class StepperLinearActuator( protected var currentPercent: Int = 0 override fun extendTo(percentage: Int): Boolean { - // checks things - if (limitCheck(percentage)) return true - // out of range or already there if (percentage < 0 || percentage > 100 || percentage == currentPercent) return true diff --git a/src/main/kotlin/crackers/kobots/parts/movement/Movements.kt b/src/main/kotlin/crackers/kobots/parts/movement/Movements.kt index d3c6f9e..07d0ce1 100644 --- a/src/main/kotlin/crackers/kobots/parts/movement/Movements.kt +++ b/src/main/kotlin/crackers/kobots/parts/movement/Movements.kt @@ -35,6 +35,11 @@ interface Actuator { * Perform the [Movement] and return `true` if the movement was successful/completed. */ infix fun move(movement: M): Boolean + + /** + * Return the currently _set_ value (e.g. where the actuator "is") + */ + fun current(): Number } /** @@ -50,16 +55,6 @@ interface StepperActuator { * Allows for "re-calibration" of the stepper's position. */ fun reset() - - /** - * This should be called as part of the movement to check whether the stepper is at limits or not. The requested - * [whereTo] is supplied so that the check can determine what might be happening. - * - * Note that this also allows the actuator to "reset" any internal variables to keep a more accurate position. - * - * @return `true` if the limit has been reached - */ - fun limitCheck(whereTo: Int): Boolean = false } /** diff --git a/src/main/kotlin/crackers/kobots/parts/movement/Rotator.kt b/src/main/kotlin/crackers/kobots/parts/movement/Rotator.kt index 0edd4b1..d8b41d7 100644 --- a/src/main/kotlin/crackers/kobots/parts/movement/Rotator.kt +++ b/src/main/kotlin/crackers/kobots/parts/movement/Rotator.kt @@ -34,7 +34,7 @@ import kotlin.math.roundToInt */ interface Rotator : Actuator { /** - * Take a "step" towards this destination. Returns `true` if the target has been reached. + * Rotate towards the target and return `true` when completed */ override infix fun move(movement: RotationMovement): Boolean { return rotateTo(movement.angle) @@ -53,7 +53,7 @@ interface Rotator : Actuator { /** * Current location. */ - fun current(): Int + override fun current(): Int } /** @@ -62,8 +62,7 @@ interface Rotator : Actuator { * * **NOTE** The accuracy of the movement is dependent on rounding errors in the calculation of the number of steps * required to reach the destination. The _timing_ of each step may also affect if the motor receives the pulse or - * not. The intent of this "device" is to be _repeatable_. Note that [stepStyle] and [stepsPerRotation] (default to - * single-stepping and the native rotation of the stepper) should be adjusted. + * not. The intent of this device is to be _repeatable_. * * [theStepper] _should_ be "released" after use to avoid motor burnout and to allow for "re-calibration" if necessary. */ @@ -105,9 +104,6 @@ open class BasicStepperRotator( override fun current(): Int = angleLocation override fun rotateTo(angle: Int): Boolean { - // checks things - if (limitCheck(angle)) return true - // first check to see if the angles already match if (angleLocation == angle) return true @@ -124,15 +120,11 @@ open class BasicStepperRotator( if (destinationSteps < stepsLocation) { stepsLocation-- theStepper.step(backwardDirection, stepStyle) - stepsToDegrees[stepsLocation]?.also { anglesForStep -> - angleLocation = if (stepsLocation !in anglesForStep) anglesForStep.max() else anglesForStep.min() - } + angleLocation = stepsToDegrees[stepsLocation]?.min() ?: angleLocation } else { stepsLocation++ theStepper.step(forwardDirection, stepStyle) - stepsToDegrees[stepsLocation]?.also { anglesForStep -> - angleLocation = if (stepsLocation !in anglesForStep) anglesForStep.min() else anglesForStep.max() - } + angleLocation = stepsToDegrees[stepsLocation]?.max() ?: angleLocation } // are we there yet? return (destinationSteps == stepsLocation).also { @@ -238,6 +230,7 @@ open class ServoRotator( private val availableDegrees = degreesToServo.keys.toList() + // where this thing thinks it is -- internal for testing purposes **only** internal var where = physicalRange.first override fun current(): Int = where diff --git a/src/main/kotlin/crackers/kobots/parts/movement/SequenceExecutor.kt b/src/main/kotlin/crackers/kobots/parts/movement/SequenceExecutor.kt index f215876..3702fbf 100644 --- a/src/main/kotlin/crackers/kobots/parts/movement/SequenceExecutor.kt +++ b/src/main/kotlin/crackers/kobots/parts/movement/SequenceExecutor.kt @@ -18,24 +18,25 @@ package crackers.kobots.parts.movement import crackers.kobots.mqtt.KobotsMQTT import crackers.kobots.mqtt.KobotsMQTT.Companion.KOBOTS_EVENTS -import crackers.kobots.parts.app.* import org.json.JSONObject import org.slf4j.Logger import org.slf4j.LoggerFactory import java.time.Duration +import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference /** * Request to execute a sequence of actions. */ -class SequenceRequest(val sequence: ActionSequence) : KobotsAction +class SequenceRequest(val sequence: ActionSequence) /** * Handles running a sequence for a thing. Every sequence is executed on a background thread that runs until - * completion or until the [stop] method is called or if an [EmergencyStop] is received. Only one sequence can be - * running at a time (see the [moveInProgress] flag). + * completion or until the [stop] method is called. Only one sequence can be running at a time (see the [moveInProgress] + * flag). * * Default execution speeds are: * - VERY_SLOW = 100ms @@ -66,36 +67,34 @@ abstract class SequenceExecutor( get() = _moving.get() private set(value) = _moving.set(value) - private val _stop = AtomicBoolean(false) - var stopImmediately: Boolean - get() = _stop.get() - private set(value) = _stop.set(value) + data class SequenceEvent(val source: String, val sequence: String, val started: Boolean) - data class SequenceEvent(val source: String, val sequence: String, val started: Boolean) : KobotsEvent + private var stopLatch: CountDownLatch? = null // blech, but better than sleeping /** * Sets the stop flag and blocks until the flag is cleared. */ open fun stop() { - stopImmediately = moveInProgress - while (stopImmediately) KobotSleep.millis(5) + if (moveInProgress) { + stopLatch = CountDownLatch(1) + // should be quick + moveInProgress = false + if (!stopLatch!!.await(5, TimeUnit.SECONDS)) { + throw IllegalStateException("Execution did not respond to 'stop' invocation.") + } + } } protected val currentSequence = AtomicReference() protected abstract fun canRun(): Boolean /** - * Handles a request. If the request is a sequence, it is executed on a background thread. If the request is an - * [EmergencyStop], the [stopImmediately] flag is set. + * Handles a request. If the request is a sequence, it is executed on a background thread. * * This function is non-blocking. */ - open fun handleRequest(request: KobotsAction) { - when (request) { - is EmergencyStop -> stopImmediately = true - is SequenceRequest -> executeSequence(request) - else -> {} - } + open fun handleRequest(request: SequenceRequest) { + executeSequence(request) } infix fun does(actionSequence: ActionSequence) = handleRequest(SequenceRequest(actionSequence)) @@ -117,15 +116,12 @@ abstract class SequenceExecutor( // publish start event to the masses val startMessage = SequenceEvent(executorName, sequenceName, true) mqttClient.publish(KOBOTS_EVENTS, JSONObject(startMessage)) - publishToTopic(INTERNAL_TOPIC, startMessage) preExecution() try { request.sequence.build().forEach { action -> - val maxPause = Duration.ofMillis(action.speed.millis) - // while can run, not stopping, and the action is not done... - while (canRun() && !stopImmediately && !action.action.step(maxPause)) { + while (canRun() && moveInProgress && !action.action.step(Duration.ofMillis(action.speed.millis))) { updateCurrentState() } } @@ -138,12 +134,11 @@ abstract class SequenceExecutor( // done _moving.set(false) updateCurrentState() - _stop.set(false) // clear emergency stop flag + stopLatch?.countDown() // publish completion event to the masses val completedMessage = SequenceEvent(executorName, sequenceName, false) mqttClient.publish(KOBOTS_EVENTS, JSONObject(completedMessage)) - publishToTopic(INTERNAL_TOPIC, completedMessage) } } @@ -161,11 +156,4 @@ abstract class SequenceExecutor( * Optionally updates the state of the executor. */ open fun updateCurrentState() {} - - companion object { - const val INTERNAL_TOPIC = "Executor.Sequences" - - @Deprecated("Use KOBOTS_EVENTS instead", ReplaceWith("KOBOTS_EVENTS")) - const val MQTT_TOPIC = KOBOTS_EVENTS - } } diff --git a/src/main/resources/8-ball.png b/src/main/resources/8-ball.png new file mode 100644 index 0000000..399e53f Binary files /dev/null and b/src/main/resources/8-ball.png differ diff --git a/src/test/kotlin/crackers/kobots/parts/movement/RotatorTest.kt b/src/test/kotlin/crackers/kobots/parts/movement/RotatorTest.kt index 5c04356..3728573 100644 --- a/src/test/kotlin/crackers/kobots/parts/movement/RotatorTest.kt +++ b/src/test/kotlin/crackers/kobots/parts/movement/RotatorTest.kt @@ -105,23 +105,6 @@ class RotatorTest : FunSpec( mockStepper.step(StepperMotorInterface.Direction.FORWARD, any()) } } - - /** - * Check the limiting function. - */ - test("Limit function stops movement") { - every { mockStepper.stepsPerRotation } answers { 360 } - val rotor = object : BasicStepperRotator(mockStepper) { - override fun limitCheck(whereTo: Int): Boolean { - return stepsLocation >= 45 - } - } - - rotor.test(83) - verify(exactly = 45) { - mockStepper.step(StepperMotorInterface.Direction.FORWARD, any()) - } - } } /** diff --git a/version.properties b/version.properties index 4f3b33a..667c839 100644 --- a/version.properties +++ b/version.properties @@ -2,7 +2,7 @@ #Sat Aug 24 10:40:21 PDT 2024 version.buildmeta= version.major=0 -version.minor=1 -version.patch=2 +version.minor=2 +version.patch=0 version.prerelease= -version.semver=0.1.2 +version.semver=0.2.0