diff --git a/.changeset/cold-ties-exist.md b/.changeset/cold-ties-exist.md new file mode 100644 index 00000000..794cdc49 --- /dev/null +++ b/.changeset/cold-ties-exist.md @@ -0,0 +1,5 @@ +--- +'@embr-jvm/core-designer': patch +--- + +Add an Embr splash screen for unlicensed gateways. diff --git a/.changeset/cute-chefs-trade.md b/.changeset/cute-chefs-trade.md new file mode 100644 index 00000000..a964a2f3 --- /dev/null +++ b/.changeset/cute-chefs-trade.md @@ -0,0 +1,5 @@ +--- +'@embr-jvm/core-designer': patch +--- + +Add an `Embr Help` button to the designer's Help menu. diff --git a/.changeset/fast-bears-divide.md b/.changeset/fast-bears-divide.md new file mode 100644 index 00000000..afe21d81 --- /dev/null +++ b/.changeset/fast-bears-divide.md @@ -0,0 +1,6 @@ +--- +'@embr-jvm/core-designer': patch +--- + +Add a button to the Designer's status bar for unlicensed gateways. Clicking on this button will navigate the user to Embr's documentation. +- The button's tooltip displays the license status for each Embr module. \ No newline at end of file diff --git a/.changeset/modern-cities-joke.md b/.changeset/modern-cities-joke.md new file mode 100644 index 00000000..35e0312d --- /dev/null +++ b/.changeset/modern-cities-joke.md @@ -0,0 +1,49 @@ +--- +'@embr-modules/charts': major +'@embr-modules/thermo': major +'@embr-modules/periscope': minor +'@embr-modules/snmp': minor +--- + +Introduce an optional licensing model. + +## Embr Module Licensing +- To offer a more complete support model, Musson Industrial is introducing optional licensing for the Embr suite. +- Don't worry, these modules will always remain free to use and open source. + + +Here's what's changing: + +**Standard Edition** +- Embr modules are free to use but will run in trial mode when unlicensed. +- **No functionality will be impacted by an expired trial.** +- You can remove the trial status by purchasing a low-cost license from our web store. + +**Maker Edition** +- Embr modules are free to use with no trial mode. +Licenses can be purchased at the [Musson Industrial Web Store](https://mussonindustrial.com/store). + +## Expected Questions + +- **Q:** Is Embr still open source? +- **A:** Yes! The entire Embr suite is still MIT-licensed, and this will never change. + + +- **Q:** Can I continue to use Embr Charts/Periscope/Thermo/SNMP for free? +- **A:** Yes! The modules will report as being in trial mode, but no functionality will be impacted. You will still continue to receive updates. + + +- **Q:** Why should I purchase a license? +- **A:** A license removes the trial banner and supports continued development of the Embr suite, helping ensure these modules remain useful, reliable, and actively maintained. + + +- **Q:** What if I upgrade from 8.1 to 8.3? +- **A:** License keys are not tied to a specific Ignition version and will never expire. + + +- **Q:** Will you offer redundant gateway, integrator, or bulk purchase discounts? +- **A:** We aim to keep licensing simple, affordable, and automated, so there are currently no additional discounts. + + +- **Q:** I have questions regarding licenses, special setups, or long-term support guarantees. +- **A:** We're here for you! For setups that fall outside the standard automated licensing process, please reach out so we can provide guidance. \ No newline at end of file diff --git a/libraries/core/common/src/main/kotlin/com/mussonindustrial/embr/common/Embr.kt b/libraries/core/common/src/main/kotlin/com/mussonindustrial/embr/common/Embr.kt index d4a998cd..06ce66bc 100644 --- a/libraries/core/common/src/main/kotlin/com/mussonindustrial/embr/common/Embr.kt +++ b/libraries/core/common/src/main/kotlin/com/mussonindustrial/embr/common/Embr.kt @@ -1,15 +1,23 @@ package com.mussonindustrial.embr.common +import com.inductiveautomation.ignition.common.model.PlatformEdition + object Embr { val CHARTS = EmbrModuleMeta("com.mussonindustrial.embr.charts", "embr-charts", "/embr/charts") - + val PERISCOPE = + EmbrModuleMeta("com.mussonindustrial.embr.periscope", "embr-periscope", "/embr/periscope") val EVENT_STREAM = EmbrModuleMeta( "com.mussonindustrial.embr.eventstream", "embr-event-stream", "/embr/event-stream", ) - val SNMP = EmbrModuleMeta("com.mussonindustrial.embr.snmp", "embr-snmp", "/embr/snmp") val THERMO = EmbrModuleMeta("com.mussonindustrial.embr.thermo", "embr-thermo", "/embr/thermo") + + const val DOCUMENTATION_URL = "https://docs.mussonindustrial.com/" + + fun isLicenseRequired(): Boolean { + return !PlatformEdition.isMaker() + } } diff --git a/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/EmbrDesignerContext.kt b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/EmbrDesignerContext.kt index 516202e9..cf2dcdb3 100644 --- a/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/EmbrDesignerContext.kt +++ b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/EmbrDesignerContext.kt @@ -1,7 +1,16 @@ package com.mussonindustrial.embr.designer +import com.inductiveautomation.ignition.common.licensing.LicenseMode +import com.inductiveautomation.ignition.common.modules.ModuleInfo import com.inductiveautomation.ignition.designer.model.DesignerContext import com.mussonindustrial.embr.common.EmbrCommonContextExtension interface EmbrDesignerContext : - DesignerContext, EmbrCommonContextExtension, EmbrDesignerContextExtension + DesignerContext, EmbrCommonContextExtension, EmbrDesignerContextExtension { + + val embrModules: List + get() = modules.filter { it.id.contains("com.mussonindustrial.embr") }.sortedBy { it.name } + + val unlicensedEmbrModules: List + get() = embrModules.filter { getLicenseState(it.id).licenseMode != LicenseMode.Activated } +} diff --git a/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/EmbrDesignerContextImpl.kt b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/EmbrDesignerContextImpl.kt index 26489690..4c8f4d44 100644 --- a/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/EmbrDesignerContextImpl.kt +++ b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/EmbrDesignerContextImpl.kt @@ -3,8 +3,50 @@ package com.mussonindustrial.embr.designer import com.inductiveautomation.ignition.designer.model.DesignerContext import com.mussonindustrial.embr.common.EmbrCommonContextExtension import com.mussonindustrial.embr.common.EmbrCommonContextExtensionImpl +import com.mussonindustrial.embr.designer.gui.EmbrHelpMenuItem +import com.mussonindustrial.embr.designer.gui.EmbrStartupModal +import com.mussonindustrial.embr.designer.gui.EmbrStatusBarButton +import com.mussonindustrial.embr.designer.gui.onWindowOpenedOnce +import javax.swing.JFrame +import javax.swing.JMenu open class EmbrDesignerContextImpl(private val context: DesignerContext) : EmbrDesignerContext, DesignerContext by context, - EmbrCommonContextExtension by EmbrCommonContextExtensionImpl(context) + EmbrCommonContextExtension by EmbrCommonContextExtensionImpl(context) { + + init { + if (!isInitialized()) initialize() + } + + val helpMenu: JMenu? + get() { + val frame = context.frame as JFrame + val jMenuBar = frame.jMenuBar + for (menuIndex in 0..jMenuBar.menuCount) { + val menu = jMenuBar.getMenu(menuIndex) + if (menu.text == "Help") return menu + } + return null + } + + private fun initialize() { + setInitialized() + + context.frame.onWindowOpenedOnce { + helpMenu?.insert(EmbrHelpMenuItem(), 1) + if (unlicensedEmbrModules.isNotEmpty()) { + EmbrStartupModal() + statusBar.addDisplay(EmbrStatusBarButton(this), 1) + } + } + } + + private fun isInitialized(): Boolean { + return System.getProperty("embr.designer.initialized")?.isNotEmpty() == true + } + + private fun setInitialized() { + System.setProperty("embr.designer.initialized", "true") + } +} diff --git a/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/AnimationUtils.kt b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/AnimationUtils.kt new file mode 100644 index 00000000..550175bc --- /dev/null +++ b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/AnimationUtils.kt @@ -0,0 +1,39 @@ +package com.mussonindustrial.embr.designer.gui + +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import javax.swing.SwingUtilities + +fun ScheduledExecutorService.animate( + durationMs: Long, + periodMs: Long = 16L, + update: (Float) -> Unit, + done: () -> Unit, +) { + val start = System.nanoTime() + + val task = + object : Runnable { + lateinit var future: ScheduledFuture<*> + + override fun run() { + val elapsed = (System.nanoTime() - start) / 1_000_000f + val t = (elapsed / durationMs).coerceIn(0f, 1f) + + SwingUtilities.invokeLater { + update(t) + if (t >= 1f) { + future.cancel(false) + done() + } + } + } + } + + task.future = scheduleAtFixedRate(task, 0, periodMs, TimeUnit.MILLISECONDS) +} + +fun easeOut(t: Float) = 1 - (1 - t) * (1 - t) + +fun easeIn(t: Float) = t * t diff --git a/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/EmbrDesignerIcons.kt b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/EmbrDesignerIcons.kt new file mode 100644 index 00000000..b5f52bed --- /dev/null +++ b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/EmbrDesignerIcons.kt @@ -0,0 +1,9 @@ +package com.mussonindustrial.embr.designer.gui + +import com.inductiveautomation.ignition.client.icons.SvgIconUtil +import javax.swing.Icon + +object EmbrDesignerIcons { + val emblem: Icon = SvgIconUtil.getIcon("mussonindustrial_emblem") + val help: Icon = SvgIconUtil.getIcon("mussonindustrial_help") +} diff --git a/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/EmbrHelpMenuItem.kt b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/EmbrHelpMenuItem.kt new file mode 100644 index 00000000..bc14dcc7 --- /dev/null +++ b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/EmbrHelpMenuItem.kt @@ -0,0 +1,13 @@ +package com.mussonindustrial.embr.designer.gui + +import com.inductiveautomation.ignition.client.util.BrowserLauncher +import com.mussonindustrial.embr.common.Embr +import javax.swing.JMenuItem + +class EmbrHelpMenuItem : JMenuItem("Embr Help") { + init { + toolTipText = "Launch the Embr user manual in a web browser" + icon = EmbrDesignerIcons.help + addActionListener { BrowserLauncher.openURL(Embr.DOCUMENTATION_URL) } + } +} diff --git a/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/EmbrStartupModal.kt b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/EmbrStartupModal.kt new file mode 100644 index 00000000..6769541a --- /dev/null +++ b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/EmbrStartupModal.kt @@ -0,0 +1,61 @@ +package com.mussonindustrial.embr.designer.gui + +import java.awt.* +import java.util.concurrent.Executors +import javax.imageio.ImageIO +import javax.swing.* +import javax.swing.Timer + +class EmbrStartupModal : JWindow() { + + private val executor = Executors.newSingleThreadScheduledExecutor() + private val autocloseDelay = 1000 + private val fadeDuration = 300L + + init { + isAlwaysOnTop = true + background = Color(0, 0, 0, 0) + opacity = 0f + + val icon = loadResourceIcon("/images/startup/modal.png") + val label = JLabel(icon).apply { isOpaque = false } + + contentPane.add(label) + pack() + setLocationRelativeTo(null) + + isVisible = true + fadeIn { + Timer(autocloseDelay) { fadeOut() } + .apply { + isRepeats = false + start() + } + } + } + + private fun fadeIn(onComplete: () -> Unit) { + executor.animate(fadeDuration, update = { t -> opacity = easeIn(t) }, done = onComplete) + } + + private fun fadeOut() { + executor.animate( + fadeDuration, + update = { t -> opacity = 1f - easeIn(t) }, + done = { + dispose() + executor.shutdownNow() + }, + ) + } + + private fun loadResourceIcon(path: String): ImageIcon? { + return try { + val res = this::class.java.getResource(path) + if (res != null) ImageIcon(ImageIO.read(res)) else null + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} diff --git a/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/EmbrStatusBarButton.kt b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/EmbrStatusBarButton.kt new file mode 100644 index 00000000..f9034f5a --- /dev/null +++ b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/EmbrStatusBarButton.kt @@ -0,0 +1,58 @@ +package com.mussonindustrial.embr.designer.gui + +import com.inductiveautomation.ignition.client.util.BrowserLauncher +import com.inductiveautomation.ignition.common.licensing.LicenseMode +import com.mussonindustrial.embr.common.Embr +import com.mussonindustrial.embr.designer.EmbrDesignerContext +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JLabel +import javax.swing.SwingUtilities + +class EmbrStatusBarButton(private val context: EmbrDesignerContext) : JLabel() { + + val colorRed = "#ff5c5c" + val colorGreen = "#5cff7c" + + init { + icon = EmbrDesignerIcons.emblem + text = if (context.unlicensedEmbrModules.isNotEmpty()) "Embr (UNLICENSED)" else "" + toolTipText = buildTooltipHtml() + + addMouseListener( + object : MouseAdapter() { + override fun mousePressed(e: MouseEvent) { + if (SwingUtilities.isLeftMouseButton(e)) { + BrowserLauncher.openURL(Embr.DOCUMENTATION_URL) + } + } + } + ) + } + + private fun buildTooltipHtml(): String { + val moduleList = + context.embrModules.joinToString("
") { module -> + val licenseMode = context.getLicenseState(module.id).licenseMode + val isLicensed = + licenseMode == LicenseMode.Activated || licenseMode == LicenseMode.Free + if (isLicensed) { + "${module.name}" + } else { + "${module.name} (UNLICENSED)" + } + } + + return """ + + Embr by Musson Industrial +

This gateway is utilizing Embr, a collection of open-source modules by Musson Industrial.

+ $moduleList +
+ You can view the documentation by clicking here.
+ Need support? We can help! + + """ + .trimIndent() + } +} diff --git a/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/WindowExtensions.kt b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/WindowExtensions.kt new file mode 100644 index 00000000..16a318fd --- /dev/null +++ b/libraries/core/designer/src/main/kotlin/com/mussonindustrial/embr/designer/gui/WindowExtensions.kt @@ -0,0 +1,16 @@ +package com.mussonindustrial.embr.designer.gui + +import java.awt.Window +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent + +fun Window.onWindowOpenedOnce(block: (WindowEvent) -> Unit) { + addWindowListener( + object : WindowAdapter() { + override fun windowOpened(e: WindowEvent) { + removeWindowListener(this) + block(e) + } + } + ) +} diff --git a/libraries/core/designer/src/main/resources/images/startup/modal.png b/libraries/core/designer/src/main/resources/images/startup/modal.png new file mode 100644 index 00000000..fbcbb799 Binary files /dev/null and b/libraries/core/designer/src/main/resources/images/startup/modal.png differ diff --git a/libraries/core/designer/src/main/resources/images/svgicons/mussonindustrial_emblem.svg b/libraries/core/designer/src/main/resources/images/svgicons/mussonindustrial_emblem.svg new file mode 100644 index 00000000..19ddb809 --- /dev/null +++ b/libraries/core/designer/src/main/resources/images/svgicons/mussonindustrial_emblem.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/libraries/core/designer/src/main/resources/images/svgicons/mussonindustrial_help.svg b/libraries/core/designer/src/main/resources/images/svgicons/mussonindustrial_help.svg new file mode 100644 index 00000000..a82f92b9 --- /dev/null +++ b/libraries/core/designer/src/main/resources/images/svgicons/mussonindustrial_help.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/libraries/core/gateway/src/main/kotlin/com/mussonindustrial/embr/gateway/EmbrGatewayContextImpl.kt b/libraries/core/gateway/src/main/kotlin/com/mussonindustrial/embr/gateway/EmbrGatewayContextImpl.kt index d37eecfc..6e0a550f 100644 --- a/libraries/core/gateway/src/main/kotlin/com/mussonindustrial/embr/gateway/EmbrGatewayContextImpl.kt +++ b/libraries/core/gateway/src/main/kotlin/com/mussonindustrial/embr/gateway/EmbrGatewayContextImpl.kt @@ -1,10 +1,21 @@ package com.mussonindustrial.embr.gateway +import com.inductiveautomation.ignition.gateway.model.DiagnosticsManager import com.inductiveautomation.ignition.gateway.model.GatewayContext +import com.inductiveautomation.ignition.gateway.model.TelemetryManager import com.mussonindustrial.embr.common.EmbrCommonContextExtension import com.mussonindustrial.embr.common.EmbrCommonContextExtensionImpl open class EmbrGatewayContextImpl(private val context: GatewayContext) : EmbrGatewayContext, GatewayContext by context, - EmbrCommonContextExtension by EmbrCommonContextExtensionImpl(context) + EmbrCommonContextExtension by EmbrCommonContextExtensionImpl(context) { + + override fun getTelemetryManager(): TelemetryManager { + return context.telemetryManager + } + + override fun getDiagnosticsManager(): DiagnosticsManager { + return context.diagnosticsManager + } +} diff --git a/modules/charts/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/charts/ChartsGatewayHook.kt b/modules/charts/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/charts/ChartsGatewayHook.kt index ec727fd8..72e1bdf7 100644 --- a/modules/charts/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/charts/ChartsGatewayHook.kt +++ b/modules/charts/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/charts/ChartsGatewayHook.kt @@ -48,7 +48,7 @@ class ChartsGatewayHook : AbstractGatewayModuleHook() { } override fun isFreeModule(): Boolean { - return true + return !Embr.isLicenseRequired() } override fun isMakerEditionCompatible(): Boolean { diff --git a/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeGatewayHook.kt b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeGatewayHook.kt index 6e9500c4..6ff6e3ac 100644 --- a/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeGatewayHook.kt +++ b/modules/periscope/gateway/src/main/kotlin/com/mussonindustrial/ignition/embr/periscope/PeriscopeGatewayHook.kt @@ -6,8 +6,8 @@ import com.inductiveautomation.ignition.common.script.ScriptManager import com.inductiveautomation.ignition.common.script.hints.PropertiesFileDocProvider import com.inductiveautomation.ignition.gateway.model.AbstractGatewayModuleHook import com.inductiveautomation.ignition.gateway.model.GatewayContext +import com.mussonindustrial.embr.common.Embr import com.mussonindustrial.ignition.embr.periscope.Meta.SHORT_MODULE_ID -import com.mussonindustrial.ignition.embr.periscope.component.embedding.* import com.mussonindustrial.ignition.embr.periscope.scripting.JavaScriptFunctions import com.mussonindustrial.ignition.embr.periscope.scripting.QueueFunctions import java.util.* @@ -56,7 +56,7 @@ class PeriscopeGatewayHook : AbstractGatewayModuleHook() { } override fun isFreeModule(): Boolean { - return true + return !Embr.isLicenseRequired() } override fun isMakerEditionCompatible(): Boolean { diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/SnmpGatewayHook.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/SnmpGatewayHook.kt index 63383e58..a6d3528a 100644 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/SnmpGatewayHook.kt +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/SnmpGatewayHook.kt @@ -74,7 +74,7 @@ class SnmpGatewayHook : AbstractDeviceModuleHook() { } override fun isFreeModule(): Boolean { - return true + return !Embr.isLicenseRequired() } override fun isMakerEditionCompatible(): Boolean { diff --git a/modules/thermo/gateway/src/main/kotlin/com/mussonindustrial/embr/thermo/ThermoGatewayHook.kt b/modules/thermo/gateway/src/main/kotlin/com/mussonindustrial/embr/thermo/ThermoGatewayHook.kt index f542258f..5ae8e58c 100644 --- a/modules/thermo/gateway/src/main/kotlin/com/mussonindustrial/embr/thermo/ThermoGatewayHook.kt +++ b/modules/thermo/gateway/src/main/kotlin/com/mussonindustrial/embr/thermo/ThermoGatewayHook.kt @@ -6,6 +6,7 @@ import com.inductiveautomation.ignition.common.script.ScriptManager import com.inductiveautomation.ignition.common.script.hints.PropertiesFileDocProvider import com.inductiveautomation.ignition.gateway.model.AbstractGatewayModuleHook import com.inductiveautomation.ignition.gateway.model.GatewayContext +import com.mussonindustrial.embr.common.Embr import com.mussonindustrial.embr.common.logging.getLogger import com.mussonindustrial.embr.thermo.expressions.IF97ExpressionFunction import com.mussonindustrial.embr.thermo.scripting.IF97ScriptModuleImpl @@ -55,7 +56,7 @@ class ThermoGatewayHook : AbstractGatewayModuleHook() { } override fun isFreeModule(): Boolean { - return true + return !Embr.isLicenseRequired() } override fun isMakerEditionCompatible(): Boolean {