Bit more reorg and adding a README

main
Oliver Kennedy 2023-06-02 18:16:32 -04:00
parent 2ca33e0ef8
commit f1d5654bf7
Signed by: okennedy
GPG Key ID: 3E5F9B3ABD3FDB60
8 changed files with 225 additions and 67 deletions

141
README.md Normal file
View File

@ -0,0 +1,141 @@
## Shingle
Stream-based home automation.
## Getting Started
#### Prerequisites
Although it is not required, Shingle works better if you have an MQTT server. We recommend [Mosquitto](https://mosquitto.org/)
#### Setup
Create a folder `~/.shingle` and a file `~/.shingle/config.json`:
```
{
}
```
#### Example Script
Create a folder `~/.shingle/scripts/` and a file `~/.shingle/scripts/Test.scala`
```
TimeEvents.every(3.seconds)
.trigger { _ => println("Hi!") }
```
#### Run Shingle
```
java Shingle.jar
```
or from the source directory
```
mill shingle.run
```
After shingle initializes itself, you should see it print `Hi!` every 3 seconds or so.
## Concepts
The core concept of Shingle is a `Stream`, which delivers a series of events. Streams are typed. For example `Stream[Boolean]` delivers a series of Boolean-valued events.
#### Creating Streams
Streams are usually created through one of Shingle's components. In the example above, `TimeEvents.every(3.seconds)` creates a Unit-valued stream that delivers an event (roughly) every 3 seconds. See below for a full list of these.
#### Consuming Streams with Trigger
`Stream`'s `trigger` method allows you to define a handler that is triggered whenever an event occurs.
#### Transforming Streams
`Stream` defines several methods that allow you to modify a stream:
* `map { event => ??? }`: Generates a new stream by applying the provided logic to transform each event in the incoming stream.
* `filter { event => ??? }`: Generates a new stream that includes only those events for which the provided logic evaluates to true.
* `join(other)`: Generates a new stream by merging events from two streams. Each event in the new stream will be a 2-tuple of the most recent events from the left and right streams.
* `debounce(delay)`: Generates a new stream where events are blocked until the stream has not seen an event for at least `delay` milliseconds.
* `onAnyChange`: Generates a new stream where duplicate events are dropped. The first event is ignored.
* `onAnyChangeIncludingFirst`: Generates a new stream where duplicate events are dropped. The first event is passed through.
## Components
#### MQTT
MQTT is a simple, low-overhead message bus. To use MQTT, your config file must have an MQTT segment defined:
```
"mqtt" : {
"host" : "tcp://your_mqtt_host_here:port"
}
```
The MQTT component provides hierarchical access to topics hosted by the MQTT server:
* `Mqtt("topic/path/here") << value`: Publish `value` to the topic. The value may be any of the basic primitive types, or a Scala class with an implicit `ujson` Codec defined.
* `Mqtt("topic/path/here").subscribe`: A `Stream[Array[Byte]]` pointing to the specified topic.
* `var x = Mqtt("topic/path/here").isState`: `x` will refer to the most recent datum sent over the topic. You may use `x.as[]` or any of the standard `ujson` primitive value decoders (i.e., `.str`, `.int`, etc...).
#### Hermes
Connect to a Hermes-based voice assistant protocol, such as Rhasspy. To use Hermes, you need MQTT configured (as above) and your voice assistant must be configured to talk over MQTT. Your config file must also have a Hermes segment defined:
```
"hermes" : {
"default" : {
"siteId" : "default_hermes_target_here"
}
}
```
The Hermes component provides support for intent handling and speech-to-text.
* `Hermes.say(text, siteId = default)`: Have your voice assistant speak the provided text aloud. If siteId is not provided, the default site from your configuration will be used.
* `Hermes.registerIntent(id) { parameters => ??? }`: Register an intent handler for intents with the provided `id`. If the intent has a payload, parameters will be passed as a `Map[String,ujson.Value]`.
#### Hass
Connect to a Home Assistant server. To use Hass, you need to provide access credentials in your config file:
```
"hass" : {
"token" : "_____",
"host" : "https://hass_server:port"
}
```
The Hass component provides support for publishing state to Home Assistant:
* `Hass.set(entity, state[, attribute1 -> value1[, attribute2 -> value2[, ...]]])`: Set the state of the provided entity. Attributes may be provided as `String -> ujson.Value` pairs.
* `Hass.service(domain, service, data1 -> value1[, data2 -> value2[, ...]]])`: Invoke the specified service. Data parameters may be provided as `String -> ujson.Value` pairs.
#### Timer
Utilities for working with time. The Timer component provides support for triggering events periodically
* `Timer.every(duration)`: A `Stream[Unit]` that is triggered roughly with a period of `duration`.
* `Timer.cron(start, end)`: A `Stream[Boolean]` that is true in the time interval between the cronstring `start` and the cronstring `end`, and false otherwise.
#### RestAPI
Utilities for working with REST APIs.
* `RestAPI(url).poll(topic, interval = 1.hour)`: Poll the provided URL every interval and post the result to the specified MQTT topic.
* `RestAPI(url).get`: Retrieve the current contents of the GET url as a Scala `Try[String]`
## Compiling Shingle
#### Setup
You need to install scala and mill. The easiest way to do this is with [Coursier](https://get-coursier.io/docs/cli-installation).
```
cs setup
cs install mill
```
#### Basic compilation
```
mill shingle.compile
```
Use the `-w` flag for iterative compilation
```
mill -w shingle.compile
```
#### Running Shingle
```
mill shingle.run
```
#### Deploying Shingle
```
mill shingle.assembly
cp out/shingle/assembly.dest/out.jar Shingle.jar
```

View File

@ -1,6 +1,5 @@
package net.okennedy.shingle
import java.time.Duration
import net.okennedy.shingle.Shingle
import org.fusesource.mqtt.client.QoS
import scala.util.Try
@ -9,6 +8,7 @@ import scala.util.Success
import net.okennedy.shingle.stream.Owner
import net.okennedy.shingle.cron.IntervalCronEvent
import net.okennedy.shingle.cron.Cron
import scala.concurrent.duration._
case class RestAPI(
val url: String,
@ -27,7 +27,7 @@ case class RestAPI(
else { Failure(new Exception(s"Result ${result.statusCode} when fetching $url")) }
}
def poll(topic: MqttPath, interval: Duration = Duration.ofHours(1))(implicit owner: Owner) =
def poll(topic: MqttPath, interval: FiniteDuration = 1.hour)(implicit owner: Owner) =
Cron.add(
new IntervalCronEvent(interval) {
// override def immediate = true

View File

@ -15,7 +15,7 @@ object Shingle
override def port: Int = 4000
val config = ujson.read(Source.fromFile(
System.getProperty("user.home") + File.separator + ".shingle"
System.getProperty("user.home") + File.separator + ".shingle" + File.separator + "config.json"
).getLines.mkString("\n"))
override def main(args: Array[String]): Unit =
@ -23,13 +23,13 @@ object Shingle
println("Welcome to Shingle\nInitializing libraries...")
println("...Mqtt")
Mqtt.start()
// Mqtt.start()
println("...MPD")
MPD
// MPD
println("...Hass")
Hass
// Hass
println("...Weather")
Weather
// Weather
println("...Cron")
Cron.handler.start()
println("Installing user modules...")

View File

@ -5,6 +5,12 @@ import java.time.temporal.ChronoUnit
import org.fusesource.mqtt.client.QoS
import upickle.default._
import net.okennedy.shingle.stream.Stream
import net.okennedy.shingle.stream.Owner
import net.okennedy.shingle.stream.Dispatchable
import net.okennedy.shingle.cron.RawScheduledCronEvent
import net.okennedy.shingle.cron.Cron
import scala.concurrent.duration.FiniteDuration
import net.okennedy.shingle.cron.IntervalCronEvent
class Timer(
topic: MqttPath,
@ -56,6 +62,54 @@ class Timer(
def abort() = aborted = true
}
object Timer
{
def apply(topic: String) = new Timer(Mqtt(topic))
def cron(
start: String,
end: String
)(implicit owner: Owner): Stream[Boolean] =
{
val stream = new Dispatchable[Boolean]() {}
val startTrigger =
new RawScheduledCronEvent(start) {
def trigger() = { stream.dispatch(true) }
}
val endTrigger =
new RawScheduledCronEvent(end) {
def trigger() = { stream.dispatch(false) }
}
owner.onPostInit { () =>
if(startTrigger.nextTrigger.compareTo(endTrigger.nextTrigger) < 0){
//startTrigger < endTrigger
stream.dispatch(true)
} else {
stream.dispatch(false)
}
}
Cron.add(startTrigger)
Cron.add(endTrigger)
return stream
}
def every(interval: FiniteDuration)(implicit owner: Owner): Stream[Unit] =
{
val stream = new Dispatchable[Unit]() {}
val trigger =
new IntervalCronEvent(interval) {
def trigger() = { stream.dispatch( () ) }
}
Cron.add(trigger)
return stream
}
}
case class TimerStatus(
start: String,
end: String,

View File

@ -2,10 +2,8 @@ package net.okennedy.shingle.cron
import scala.collection.mutable
import math.Ordered.orderingToOrdered
import java.time.Clock
import java.time.temporal.TemporalUnit
import java.time.temporal.ChronoUnit
import net.okennedy.shingle.stream.Owner
import scala.concurrent.duration._
object Cron
{
@ -13,7 +11,6 @@ object Cron
def compare(a: CronEvent, b: CronEvent) =
b.nextTrigger.compare(a.nextTrigger)
})
val clock = Clock.systemDefaultZone()
val handler = new Thread {
override def run(): Unit =
@ -27,8 +24,8 @@ object Cron
Thread.sleep(1000000)
} else {
val event = synchronized { queue.head }
if(clock.instant.isBefore(event.nextTrigger)){
val sleepTime = clock.instant.until(event.nextTrigger, ChronoUnit.MILLIS)
if(event.nextTrigger.hasTimeLeft){
val sleepTime = event.nextTrigger.timeLeft.toMillis
Thread.sleep(sleepTime)
} else {
done = true

View File

@ -1,35 +1,31 @@
package net.okennedy.shingle.cron
import java.time.Duration
import java.time.Clock
import java.time.Instant
import java.util.Date
import scala.concurrent.duration._
import org.quartz.CronExpression
import java.util.Date
trait CronEvent
{
def trigger(): Unit
def nextTrigger: Instant
def nextTrigger: Deadline
def fireTrigger(): Unit = trigger()
}
trait IntervalCronEvent(interval: Duration) extends CronEvent
trait IntervalCronEvent(interval: FiniteDuration) extends CronEvent
{
def immediate: Boolean = false
def trigger(): Unit
var nextTrigger: Instant =
if(immediate){ Clock.systemDefaultZone.instant }
else {
Clock.systemDefaultZone.instant.plus(interval)
}
var nextTrigger: Deadline =
if(immediate){ Deadline.now }
else { interval.fromNow }
override def fireTrigger(): Unit =
{
try {
trigger()
} finally {
nextTrigger = Clock.systemDefaultZone.instant.plus(interval)
nextTrigger = interval.fromNow
}
}
}
@ -40,8 +36,15 @@ trait RawScheduledCronEvent(expression: String) extends CronEvent
def trigger(): Unit
def nextTrigger: Instant =
timer.getNextValidTimeAfter(new Date())
.toInstant
def nextTrigger: Deadline =
{
(
timer.getNextValidTimeAfter(new Date())
.toInstant
.getEpochSecond()
- (System.currentTimeMillis() / 1000)
).seconds
.fromNow
}
}

View File

@ -1,37 +0,0 @@
package net.okennedy.shingle.cron
import net.okennedy.shingle.stream.Owner
import net.okennedy.shingle.stream.Dispatchable
import net.okennedy.shingle.stream.Stream
object TimeRangeTriggerable
{
def cron(
start: String,
end: String
)(implicit owner: Owner): Stream[Boolean] =
{
val stream = new Dispatchable[Boolean]() {}
val startTrigger =
new RawScheduledCronEvent(start) {
def trigger() = { stream.dispatch(true) }
}
val endTrigger =
new RawScheduledCronEvent(start) {
def trigger() = { stream.dispatch(false) }
}
owner.onPostInit { () =>
if(startTrigger.nextTrigger.compareTo(endTrigger.nextTrigger) < 0){
//startTrigger < endTrigger
stream.dispatch(true)
} else {
stream.dispatch(false)
}
}
Cron.add(startTrigger)
Cron.add(endTrigger)
return stream
}
}

View File

@ -21,7 +21,7 @@ object Module
}
val SCRIPTS = new File(
System.getProperty("user.home") + File.separator + ".shingle-scripts"
System.getProperty("user.home") + File.separator + ".shingle" + File.separator + "scripts"
)
val SUFFIX = ".scala"
@ -88,7 +88,7 @@ object Module
| import net.okennedy.shingle.cron.Cron;
| import net.okennedy.shingle.module.Notification;
| import net.okennedy.shingle.module.Notifications;
| import net.okennedy.shingle.cron.TimeRangeTriggerable;
| import scala.concurrent.duration._;
| $script
|}
|""".stripMargin,