Compare commits

...

10 Commits

7 changed files with 238 additions and 301 deletions

View File

@ -13,12 +13,14 @@ object shingle extends ScalaModule
ivy"com.lihaoyi::ujson:2.0.0",
ivy"com.lihaoyi::upickle:2.0.0",
ivy"com.lihaoyi::requests:0.7.0",
ivy"com.lihaoyi::cask:0.8.3",
ivy"org.fusesource.mqtt-client:mqtt-client:1.16",
ivy"net.thejavashop:javampd:6.0.0",
ivy"org.quartz-scheduler:quartz:2.3.2",
ivy"org.slf4j:slf4j-api:1.7.36",
ivy"ch.qos.logback:logback-classic:1.2.10",
ivy"com.typesafe.scala-logging::scala-logging::3.9.4",
ivy"org.scala-lang::scala3-compiler::3.1.2"
)
}

View File

@ -1,9 +1,10 @@
#!/bin/bash
set -c
HOST=heracles.xthemage.net
DIR=Documents/shingle/
mill shingle.assembly
scp out/shingle/assembly.dest/out.jar $HOST:$DIR/shingle.jar
ssh $HOST 'systemctl --user restart shingle'
ssh $HOST 'journalctl --user -efu shingle'
# ssh -t $HOST 'sudo systemctl restart shingle & sudo journalctl -efu shingle'

View File

@ -3,325 +3,134 @@ package net.okennedy.shingle
import scala.io.Source
import java.io.File
import net.okennedy.shingle.module.Module
import java.util.Calendar
import cask.main.Routes
import net.okennedy.shingle.cron.Cron
import net.okennedy.shingle.module.Notification
import net.okennedy.shingle.module.Notifications
import net.okennedy.shingle.cron.TimeRangeTriggerable
object Shingle
extends cask.Main
{
override def allRoutes: Seq[Routes] = Seq(Webserver)
override def port: Int = 4000
val config = ujson.read(Source.fromFile(
System.getProperty("user.home") + File.separator + ".shingle"
).getLines.mkString("\n"))
def main(args: Array[String]): Unit =
override def main(args: Array[String]): Unit =
{
println("Welcome to Shingle\nInitializing libraries...")
println("...Mqtt")
Mqtt.start()
println("...MPD")
MPD
println("...Hass")
Hass
println("...Weather")
Weather
println("...Cron")
Cron.handler.start()
println("Installing user modules...")
Module.init()
println("Shingle is running.")
Module("TIME_INTENTS") { implicit ctx =>
var kitchenTimer: Timer = new Timer(Mqtt("timer/kitchen"))
kitchenTimer.done.trigger {
_ => Hermes.say("The timer is done")
}
Hermes.registerIntent("SetTimer"){ slots =>
val t = slots("count").num * slots("unit").num
val (duration, _) = Seq(
(3600, "hour"),
(60, "minute"),
(1, "second")
).foldLeft ( (Seq[String](), t.toLong) ) {
case ( (accum, remainder), (unit, label) ) =>
if(remainder > unit){
val diff = (remainder / unit).toLong
(
accum :+ s"$diff ${label}${if(diff > 1){ "s" } else { "" }}",
remainder % unit
)
} else {
(accum, remainder)
}
}
Hermes.say(s"Setting timer for ${duration.mkString(", ")}")
kitchenTimer.go(
end = java.time.LocalDateTime.now.plusSeconds(t.toLong)
)
}
}
Module("LIGHT_INTENTS") { implicit ctx =>
val lights = Map(
"living room light" -> ("light", "living_room_lamp"),
"armory light" -> ("light", "living_room_lamp"),
// "office light" -> ("switch", "office_light_relay")
)
val VALID_STATE = Set("on", "off")
Hermes.registerIntent("ChangeLightState") { slots =>
val (domain, entity) = lights(slots("name").str)
val state = slots("state").str
if(!VALID_STATE(state)){
println(s"Invalid state: $state for $domain.$entity")
return
}
println(entity+" -> "+slots("state").str)
Hass.service(
domain,
s"turn_$state",
"entity_id" -> s"$domain.$entity"
)
}
}
Module("CREEPY_SOUND") { implicit ctx =>
val sounds = Seq(
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
"ooooooooooooooooooooooooooooooooooooooooooooooooyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr",
)
Hermes.registerIntent("CreepySound")
{ _ => Hermes.say(scala.util.Random.shuffle(sounds).head) }
}
Module("GARAGE_SENSORS") { implicit ctx =>
def notify(msg: String) =
{
Hermes.say(msg)
Hass.service("notify", "matrix_home", "message" -> msg)
}
Mqtt("sensor/garage/door")
.onAnyChange
.asString
.trigger {
// temporary bug in the sensor code flips the states...
case "open" => notify("The garage door is closed")
case "closed" => notify("The garage door is open")
case _ => ()
}
Mqtt("sensor/garage/distance")
.asString
.map { _.toInt }
.map {
case x if x.toInt < 200 => "parked"
case x if x.toInt < 250 => "parking-near"
case x if x.toInt < 300 => "parking-far"
case _ => "empty"
}
.aliasAsString("sensor/garage/parking")
.onAnyChange
.asJson[String]
.debounce(5000)
.trigger {
case "parked" => notify("The car is parked")
case "empty" => notify("The car has departed")
case _ => ()
}
}
Module("GARAGE_LIGHT"){ implicit ctx =>
val lights = Seq(
"red", "yellow", "green"
).map { x => Mqtt(s"light/garage/tower/$x") }
def setLight(target: Int) =
for((light, idx) <- lights.zipWithIndex){
if(idx == target) { light << "on" }
else { light << "off" }
}
Mqtt("sensor/garage/parking")
.asString
.onAnyChange
.join(Mqtt("sensor/garage/door").onAnyChange.asString)
.trigger {
case ("parked" , "open") => setLight(0)
case ("parking-near", "open") => setLight(1)
case ("parking-far" , "open") => setLight(2)
case (_ , _) => setLight(3)
}
}
Module("MPD_INTENTS") { implicit ctx =>
val standardPlayer = "media_player.stereo_armory"
// Stereo Off
Hermes.registerIntent("StereoOff") { slots =>
Hermes.say("Stereo Off")
Seq(
"media_player.stereo_armory",
"media_player.stereo_living_room",
).foreach { entity =>
Hass.service("media_player", "turn_off",
"entity_id" -> ujson.Str(entity)
)
}
Hass.service("media_player", "media_pause",
"entity_id" -> ujson.Str("media_player.mpd")
)
}
// Play Standard
Hermes.registerIntent("PlayMusic"){ _ =>
Hermes.say("Playing Music")
super.main(args)
Hass.service("media_player", "turn_on",
"entity_id" -> ujson.Str(standardPlayer)
)
// Module("TIME_INTENTS") { implicit ctx =>
// var kitchenTimer: Timer = new Timer(Mqtt("timer/kitchen"))
Hass.service("media_player", "select_source",
"entity_id" -> ujson.Str(standardPlayer),
"source" -> ujson.Str("AUDIO1")
)
// kitchenTimer.done.trigger {
// _ => Hermes.say("The timer is done")
// }
Hass.service("media_player", "turn_on",
"entity_id" -> ujson.Str("media_player.mpd")
)
// Hermes.registerIntent("SetTimer"){ slots =>
// val t = slots("count").num * slots("unit").num
Hass.service("media_player", "media_play",
"entity_id" -> ujson.Str("media_player.mpd")
)
}
// val (duration, _) = Seq(
// (3600, "hour"),
// (60, "minute"),
// (1, "second")
// ).foldLeft ( (Seq[String](), t.toLong) ) {
// case ( (accum, remainder), (unit, label) ) =>
// if(remainder > unit){
// val diff = (remainder / unit).toLong
// (
// accum :+ s"$diff ${label}${if(diff > 1){ "s" } else { "" }}",
// remainder % unit
// )
// } else {
// (accum, remainder)
// }
// }
// Play Radio
Hermes.registerIntent("PlayRadio"){ _ =>
Hermes.say("Playing N.P.R.")
// Hermes.say(s"Setting timer for ${duration.mkString(", ")}")
Hass.service("media_player", "turn_on",
"entity_id" -> ujson.Str(standardPlayer)
)
// kitchenTimer.go(
// end = java.time.LocalDateTime.now.plusSeconds(t.toLong)
// )
// }
// }
Hass.service("media_player", "select_source",
"entity_id" -> ujson.Str(standardPlayer),
"source" -> ujson.Str("TUNER")
)
// Module("GARAGE_SENSORS") { implicit ctx =>
// def notify(msg: String) =
// {
// Hermes.say(msg)
// Hass.service("notify", "matrix_home", "message" -> msg)
// }
Hass.service("media_player", "media_pause",
"entity_id" -> ujson.Str("media_player.mpd")
)
}
// // Mqtt("sensor/garage/door")
// // .onAnyChange
// // .asString
// // .trigger {
// // // temporary bug in the sensor code flips the states...
// // case "closed" => notify("The garage door is closed")
// // case "open" => notify("The garage door is open")
// // case _ => ()
// // }
// Play Song
Hermes.registerIntent("PlaySong"){ slots =>
val request = slots("music").str
// Mqtt("sensor/garage/distance")
// .asString
// .map {
// case "borked" => "unknown"
// case x if x.toInt < 200 => "parked"
// case x if x.toInt < 250 => "parking-near"
// case x if x.toInt < 300 => "parking-far"
// case _ => "empty"
// }
// .aliasAsString("sensor/garage/parking")
// .onAnyChange
// .asJson[String]
// .debounce(5000)
// .trigger {
// case "parked" => notify("The car is parked")
// case "empty" => notify("The car has departed")
// case _ => ()
// }
// }
val playlist =
MPD.savedPlaylists
.find { request.equalsIgnoreCase(_) }
.getOrElse {
Hermes.say(s"I don't know how to play $request")
return
}
Hermes.say(s"Playing $request")
Hass.service("media_player", "turn_on",
"entity_id" -> ujson.Str(standardPlayer)
)
Hass.service("media_player", "select_source",
"entity_id" -> ujson.Str(standardPlayer),
"source" -> ujson.Str("AUDIO1")
)
MPD.clear()
for(item <- MPD.savedPlaylist(playlist)) {
MPD.append(item.getFile)
}
MPD.play()
}
}
Module("WEATHER"){ implicit owner =>
Hermes.registerIntent("GetTemperature"){ _ =>
val forecast = Weather.forecast
Hermes.say("One moment please.")
Hermes.say(s"The weather for ${forecast(0).name} is ${forecast(0).shortForecast} with temperatures around ${forecast(0).temperature.toInt} degrees.")
Hermes.say(s"For ${forecast(1).name}, ${forecast(1).shortForecast} with temperatures around ${forecast(1).temperature.toInt} degrees.")
}
}
Module("DAD"){ implicit owner =>
Hermes.registerIntent("TellAJoke"){ _ =>
Hermes.say("Let's think")
val resp = requests.get(
"https://icanhazdadjoke.com/",
headers = Map(
"User-Agent" -> "Shingle (https://github.com/okennedy)",
"Accept" -> "text/plain"
)
)
if(resp.statusCode != 200)
{
println(s"Joke Error: ${resp.statusCode} -> ${resp.string()}")
Hermes.say("I'm not feeling funny right now.")
} else {
println(resp.string())
Hermes.say(resp.string())
}
}
}
Module("NOTIFICATIONS"){ implicit owner =>
Notifications("garage_door") <<
Mqtt("sensor/garage/door")
.onAnyChangeIncludingFirst
.asString
.map {
case door if door == "open" =>
Some(Notification(
body = "The garage door is open",
status = Notifications.Warning,
icon = "transportation/car"
))
case door =>
None
}
Notifications("kitchen_timer") <<
Mqtt("timer/kitchen/progress")
.onAnyChangeIncludingFirst
.asJson[TimerStatus]
.map {
case timer if timer.progress < 1.0 =>
Some(Notification(
body = s"${timer.timeRemaining} on timer",
status = Notifications.Info,
icon = "alerts/alarm-clock"
))
}
Notifications("garbage") <<
TimeRangeTriggerable.cron(
// s m h dom mo dow
start = "0 0 12 ? * 5",
end = "0 0 0 ? * 6",
).map {
case true => println("Garbage On"); Some(Notification(
body = "Take out the trash",
status = Notifications.Warning,
icon = "ecology/recycling-1"
))
case false => println("Garbage Off"); None
}
}
// Module("GARAGE_LIGHT"){ implicit ctx =>
// val lights = Seq(
// "red", "yellow", "green"
// ).map { x => Mqtt(s"light/garage/tower/$x") }
// def setLight(target: Int) =
// for((light, idx) <- lights.zipWithIndex){
// if(idx == target) { light << "on" }
// else { light << "off" }
// }
// Mqtt("sensor/garage/parking")
// .asString
// .onAnyChange
// .join(Mqtt("sensor/garage/door").onAnyChange.asString)
// .trigger {
// case ("parked" , "open") => setLight(0)
// case ("parking-near", "open") => setLight(1)
// case ("parking-far" , "open") => setLight(2)
// case (_ , _) => setLight(3)
// }
// }
}
}

View File

@ -5,6 +5,8 @@ import upickle.default._
import net.okennedy.shingle.cron.IntervalCronEvent
import java.time.Duration
import net.okennedy.shingle.cron.Cron
import scala.util.Success
import scala.util.Failure
object Weather
{
@ -21,6 +23,10 @@ object Weather
reader[Seq[WeatherPrediction]]
)
api.get match {
case Success(r) => state << r
case Failure(exception) => s"Error on initial weather fetch: ${exception}"
}
api.poll(state)(Owner.global)
}
case class WeatherPrediction(

View File

@ -0,0 +1,38 @@
package net.okennedy.shingle
import net.okennedy.shingle.module.Module
object Webserver extends cask.Routes
{
@cask.get("/")
def root() = {
"Hello World"
}
@cask.get("api/modules")
def listModules() = {
Module.list.mkString("\n")
}
@cask.get("api/modules/:id")
def readModule(id: String) = {
Module.read(id)
}
@cask.get("api/load/:id")
def saveModule(id: String) = {
Module.reload(id)
}
@cask.get("api/unload/:id")
def rmModule(id: String) = {
Module.unload(id)
}
@cask.get("api/active")
def activeModules() = {
Module.active.mkString("\n")
}
initialize()
}

View File

@ -26,8 +26,11 @@ trait IntervalCronEvent(interval: Duration) extends CronEvent
override def fireTrigger(): Unit =
{
trigger()
nextTrigger = Clock.systemDefaultZone.instant.plus(interval)
try {
trigger()
} finally {
nextTrigger = Clock.systemDefaultZone.instant.plus(interval)
}
}
}

View File

@ -1,18 +1,96 @@
package net.okennedy.shingle.module
import scala.collection.mutable
import dotty.tools.repl.ScriptEngine
import java.io.File
import scala.io.Source
import java.io.FileWriter
object Module
{
val active = mutable.HashMap[String, Owner]()
val activeModules = mutable.HashMap[String, Owner]()
def apply(module: String)(f: Owner => Unit) =
{
active.remove(module).foreach { _.cleanup() }
unload(module)
val owner = new Owner(module)
f(owner)
owner.postInit()
active(module) = owner
activeModules(module) = owner
}
val SCRIPTS = new File(
System.getProperty("user.home") + File.separator + ".shingle-scripts"
)
val SUFFIX = ".scala"
def init(): Unit =
{
for(module <- list){
println(s"... installing $module")
try {
install(module, read(module))
} catch {
case t: Throwable =>
println(s"Error installing $module")
t.printStackTrace()
}
}
}
def active: Seq[String] =
activeModules.keys.toSeq
def list: Seq[String] =
if(!SCRIPTS.exists()){ Seq.empty }
else { SCRIPTS.listFiles
.map { _.getName }
.filter { _.endsWith(SUFFIX) }
.map { _.dropRight(SUFFIX.length) } }
def read(module: String): String =
{
val f = Source.fromFile(new File(SCRIPTS, module+SUFFIX))
f.getLines().mkString
}
def save(module: String, script: String): Unit =
{
val w = new FileWriter(new File(SCRIPTS, module+SUFFIX))
w.write(script)
w.close()
install(module, script)
}
def delete(module: String): Unit =
{
unload(module)
new File(SCRIPTS, module+SUFFIX).delete()
}
def reload(module: String): Unit =
{
install(module, read(module))
}
def unload(module: String): Unit =
{
activeModules.remove(module).foreach { _.cleanup() }
}
def install(module: String, script: String): Unit =
{
val engine = new ScriptEngine()
engine.eval(
s"""net.okennedy.shingle.module.Module("$module") { implicit ctx =>
| import net.okennedy.shingle._;
| import net.okennedy.shingle.cron.Cron
| import net.okennedy.shingle.module.Notification
| import net.okennedy.shingle.module.Notifications
| import net.okennedy.shingle.cron.TimeRangeTriggerable
| $script
|}
|""".stripMargin,
)
}
}