From 4a54622aea130f424fbbcdf41239e56e821e6fee Mon Sep 17 00:00:00 2001 From: "E. A. (Ed) Graham, Jr." <10370165+EAGrahamJr@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:25:32 -0700 Subject: [PATCH] API cleanup (#3) * Removed unused internal messaging * Cleaned up HomeAssistant lighting commands for effects-types * Tweaked/cleaned up `Movements` for more consistency and removed "arbitrary" limit checks * New "animation" for graphics to show an 8-ball and the classic 8-ball fortunes --- EventBus.md | 22 ---- EventBus.png | Bin 41989 -> 0 bytes EventBus.puml | 32 ----- Movements.md | 4 +- README.md | 6 +- .../kotlin/crackers/kobots/app/AppCommon.kt | 12 -- .../kobots/graphics/animation/EightBall.kt | 83 ++++++++++++ .../kotlin/crackers/kobots/mqtt/KobotsMQTT.kt | 31 ----- .../homeassistant/KobotLightController.kt | 63 ++++++---- .../mqtt/homeassistant/KobotLightDevices.kt | 44 +++---- .../homeassistant/PimoroniShimController.kt | 3 +- .../mqtt/homeassistant/PixelBufController.kt | 21 ---- .../crackers/kobots/parts/app/EventBus.kt | 118 ------------------ .../kobots/parts/movement/ActionSequence.kt | 48 +++++-- .../crackers/kobots/parts/movement/Linear.kt | 5 +- .../kobots/parts/movement/Movements.kt | 15 +-- .../crackers/kobots/parts/movement/Rotator.kt | 19 +-- .../kobots/parts/movement/SequenceExecutor.kt | 52 +++----- src/main/resources/8-ball.png | Bin 0 -> 16575 bytes .../kobots/parts/movement/RotatorTest.kt | 17 --- version.properties | 6 +- 21 files changed, 215 insertions(+), 386 deletions(-) delete mode 100644 EventBus.md delete mode 100644 EventBus.png delete mode 100644 EventBus.puml create mode 100644 src/main/kotlin/crackers/kobots/graphics/animation/EightBall.kt delete mode 100644 src/main/kotlin/crackers/kobots/parts/app/EventBus.kt create mode 100644 src/main/resources/8-ball.png 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 3bb2621b6aba12fc7de3d1613f914194734bfe2a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41989 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­8Rkg4HN5jyJmv-LCol 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 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 0000000000000000000000000000000000000000..399e53f11ec3ceb0d2fb65e85cdeb91e2a34cdbb GIT binary patch literal 16575 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 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