diff --git a/net.solarnetwork.node.setup.mobile/.classpath b/net.solarnetwork.node.setup.mobile/.classpath new file mode 100644 index 000000000..9b6e2eb5b --- /dev/null +++ b/net.solarnetwork.node.setup.mobile/.classpath @@ -0,0 +1,7 @@ + + + + + + + diff --git a/net.solarnetwork.node.setup.mobile/.project b/net.solarnetwork.node.setup.mobile/.project new file mode 100644 index 000000000..d758bc735 --- /dev/null +++ b/net.solarnetwork.node.setup.mobile/.project @@ -0,0 +1,28 @@ + + + net.solarnetwork.node.setup.mobile + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.pde.ManifestBuilder + + + + + org.eclipse.pde.SchemaBuilder + + + + + + org.eclipse.pde.PluginNature + org.eclipse.jdt.core.javanature + + diff --git a/net.solarnetwork.node.setup.mobile/.settings/org.eclipse.jdt.core.prefs b/net.solarnetwork.node.setup.mobile/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 000000000..1b9068e81 --- /dev/null +++ b/net.solarnetwork.node.setup.mobile/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,8 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled +org.eclipse.jdt.core.compiler.codegen.targetPlatform=17 +org.eclipse.jdt.core.compiler.compliance=17 +org.eclipse.jdt.core.compiler.problem.assertIdentifier=error +org.eclipse.jdt.core.compiler.problem.enumIdentifier=error +org.eclipse.jdt.core.compiler.release=disabled +org.eclipse.jdt.core.compiler.source=17 diff --git a/net.solarnetwork.node.setup.mobile/.settings/org.eclipse.pde.core.prefs b/net.solarnetwork.node.setup.mobile/.settings/org.eclipse.pde.core.prefs new file mode 100644 index 000000000..e8ff8be0b --- /dev/null +++ b/net.solarnetwork.node.setup.mobile/.settings/org.eclipse.pde.core.prefs @@ -0,0 +1,4 @@ +eclipse.preferences.version=1 +pluginProject.equinox=false +pluginProject.extensions=false +resolve.requirebundle=false diff --git a/net.solarnetwork.node.setup.mobile/META-INF/MANIFEST.MF b/net.solarnetwork.node.setup.mobile/META-INF/MANIFEST.MF new file mode 100644 index 000000000..e426b1474 --- /dev/null +++ b/net.solarnetwork.node.setup.mobile/META-INF/MANIFEST.MF @@ -0,0 +1,21 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: Mobile Network Setup +Bundle-Description: System mobile (cellular/4G) network setup support. +Bundle-SymbolicName: net.solarnetwork.node.setup.mobile +Bundle-Version: 1.0.0 +Bundle-Vendor: SolarNetwork +Automatic-Module-Name: net.solarnetwork.node.setup.mobile +Bundle-RequiredExecutionEnvironment: JavaSE-17 +Import-Package: net.solarnetwork.domain;version="[3.0,4.0)", + net.solarnetwork.node;version="[2.0,3.0)", + net.solarnetwork.node.reactor;version="[2.0,3.0)", + net.solarnetwork.node.service.support;version="[1.0,2.0)", + net.solarnetwork.service.support;version="[1.0,2.0)", + net.solarnetwork.settings;version="[2.0,3.0)", + net.solarnetwork.settings.support;version="[3.0,4.0)", + net.solarnetwork.util;version="[2.0,3.0)", + org.slf4j;version="[1.7,2.0)", + org.springframework.beans.factory;version="[6.2,7.0)", + org.springframework.context;version="[6.2,7.0)", + org.springframework.context.support;version="[6.2,7.0)" diff --git a/net.solarnetwork.node.setup.mobile/OSGI-INF/blueprint/module.xml b/net.solarnetwork.node.setup.mobile/OSGI-INF/blueprint/module.xml new file mode 100644 index 000000000..82a3db0e4 --- /dev/null +++ b/net.solarnetwork.node.setup.mobile/OSGI-INF/blueprint/module.xml @@ -0,0 +1,38 @@ + + + + + + + + + + net.solarnetwork.settings.SettingSpecifierProvider + net.solarnetwork.node.reactor.InstructionHandler + + + + + + SystemConfigure + + + + + + + + + + diff --git a/net.solarnetwork.node.setup.mobile/README.md b/net.solarnetwork.node.setup.mobile/README.md new file mode 100644 index 000000000..786a8d801 --- /dev/null +++ b/net.solarnetwork.node.setup.mobile/README.md @@ -0,0 +1,85 @@ +# SolarNode Mobile Network Setup + +This plugin provides a configurable UI within SolarNode, and `SystemConfigure` instruction +support, for resetting/restarting the node's mobile (cellular/4G) network connection. + +It is modeled on the [WiFi Setup][wifi] plugin. + +# Install + +This plugin must be manually installed, along with an OS support package that provides the `mobile` +service script for the `solarcfg` helper. On SolarNodeOS that support is provided by the +[`sn-mobile-mm`][sn-mobile-mm] package, which manages cellular connectivity via ModemManager and +installs the service script to `/usr/share/solarnode/cfg.d/mobile.sh`. + +# Use + +Once installed, a new **Mobile Network** section appears on the **Settings** page on your SolarNode, +with a **Reset Connection** toggle (the toggle returns to off after the reset is requested). + +# `SystemConfigure` instruction support + +The `SystemConfigure` instruction topic can be used to get the mobile status and to reset/restart +the connection. The `service` parameter must be `/setup/network/mobile`. The `action` parameter +selects the operation: + +| `action` | Description | +| :-------------------- | :---------------------------------------------------- | +| `status` (or omitted) | Return the current mobile connection status. | +| `reset` | Reset (disconnect + reconnect) the mobile connection. | +| `restart` | Restart the mobile networking service. | + +## Status result + +For the `status` action the `result` parameter is an object: + +| Property | Type | Description | +| :-------- | :------------- | :------------------------------------------------------------------ | +| `present` | `boolean` | `true` if a mobile modem is available (and so a reset is possible). | +| `active` | `boolean` | `true` if the mobile connection is currently active. | +| `info` | `List` | Optional detail lines (operator, access technology, signal, state). | + +A client can query `status` first and only offer/perform a `reset` when `present` is `true`, to +avoid attempting a reset on a node that has no mobile modem. If the plugin is not installed on the +node at all, the `SystemConfigure` instruction returns a not-found status instead of a result. + +Example `result`, expressed in JSON: + +```json +{ + "present": true, + "active": false, + "info": [ + "operator: Spark NZ", + "access: lte", + "signal: 64%", + "state: registered" + ] +} +``` + +## STOMP usage + +Via the SolarNode STOMP setup server, after authenticating, send a frame whose `destination` is the +service name. For example, to check whether a mobile connection is available before resetting: + +``` +SEND +destination:/setup/network/mobile +action:status + +^@ +``` + +and to reset the 4G connection: + +``` +SEND +destination:/setup/network/mobile +action:reset + +^@ +``` + +[wifi]: ../net.solarnetwork.node.setup.wifi/ +[sn-mobile-mm]: https://github.com/SolarNetwork/solarnode-os-packages/tree/develop/mobile-mm/debian diff --git a/net.solarnetwork.node.setup.mobile/build.properties b/net.solarnetwork.node.setup.mobile/build.properties new file mode 100644 index 000000000..a7f8cf52d --- /dev/null +++ b/net.solarnetwork.node.setup.mobile/build.properties @@ -0,0 +1,5 @@ +source.. = src/ +output.. = build/eclipse/ +bin.includes = META-INF/,\ + .,\ + OSGI-INF/ diff --git a/net.solarnetwork.node.setup.mobile/build.xml b/net.solarnetwork.node.setup.mobile/build.xml new file mode 100644 index 000000000..843f28da1 --- /dev/null +++ b/net.solarnetwork.node.setup.mobile/build.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/net.solarnetwork.node.setup.mobile/ivy.xml b/net.solarnetwork.node.setup.mobile/ivy.xml new file mode 100644 index 000000000..bf98df211 --- /dev/null +++ b/net.solarnetwork.node.setup.mobile/ivy.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/net.solarnetwork.node.setup.mobile/src/net/solarnetwork/node/setup/mobile/MobileConfiguration.java b/net.solarnetwork.node.setup.mobile/src/net/solarnetwork/node/setup/mobile/MobileConfiguration.java new file mode 100644 index 000000000..cd3bae2e5 --- /dev/null +++ b/net.solarnetwork.node.setup.mobile/src/net/solarnetwork/node/setup/mobile/MobileConfiguration.java @@ -0,0 +1,364 @@ +/* ================================================================== + * MobileConfiguration.java - 6/06/2026 9:00:00 AM + * + * Copyright 2026 SolarNetwork.net Dev Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA + * 02111-1307 USA + * ================================================================== + */ + +package net.solarnetwork.node.setup.mobile; + +import static net.solarnetwork.node.Constants.solarNodeHome; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import net.solarnetwork.domain.InstructionStatus.InstructionState; +import net.solarnetwork.node.reactor.Instruction; +import net.solarnetwork.node.reactor.InstructionHandler; +import net.solarnetwork.node.reactor.InstructionStatus; +import net.solarnetwork.node.reactor.InstructionUtils; +import net.solarnetwork.node.service.support.BaseIdentifiable; +import net.solarnetwork.settings.SettingSpecifier; +import net.solarnetwork.settings.SettingSpecifierProvider; +import net.solarnetwork.settings.SettingsChangeObserver; +import net.solarnetwork.settings.support.BasicTitleSettingSpecifier; +import net.solarnetwork.settings.support.BasicToggleSettingSpecifier; +import net.solarnetwork.util.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.MessageSource; + +/** + * Settings provider and instruction handler for mobile (cellular/4G) network + * configuration. + * + *

+ * This service handles the {@link InstructionHandler#TOPIC_SYSTEM_CONFIGURE} + * instruction topic when the {@link InstructionHandler#PARAM_SERVICE} parameter + * is {@link #MOBILE_SERVICE_NAME}. The {@link #PARAM_ACTION} parameter selects + * the operation to perform: + *

+ * + * + * + *

+ * All operations are delegated to the OS-specific {@code solarcfg} helper + * script, invoked as {@code solarcfg mobile }. The actual work is + * implemented by the {@code mobile} service script (for example + * {@code /usr/share/solarnode/cfg.d/mobile.sh}) provided by an OS support + * package. + *

+ * + * @author elijah + * @version 1.0 + */ +public class MobileConfiguration extends BaseIdentifiable + implements SettingSpecifierProvider, SettingsChangeObserver, InstructionHandler { + + /** The {@code solarcfg} service name for mobile networking. */ + public static final String CONFIG_SERVICE = "mobile"; + + /** The default value for the {@code command} property. */ + public static final String DEFAULT_COMMAND = solarNodeHome() + "/bin/solarcfg"; + + /** + * The {@literal service} instruction parameter value for mobile network + * configuration. + */ + public static final String MOBILE_SERVICE_NAME = "/setup/network/mobile"; + + /** The {@literal action} instruction parameter name. */ + public static final String PARAM_ACTION = "action"; + + /** The {@literal action} parameter value to return the current status. */ + public static final String ACTION_STATUS = "status"; + + /** The {@literal action} parameter value to reset the connection. */ + public static final String ACTION_RESET = "reset"; + + /** The {@literal action} parameter value to restart the service. */ + public static final String ACTION_RESTART = "restart"; + + private final Logger log = LoggerFactory.getLogger(getClass()); + + private String command = DEFAULT_COMMAND; + private boolean reset = false; + + /** + * Constructor. + */ + public MobileConfiguration() { + super(); + setUid("net.solarnetwork.node.setup.mobile.MobileConfiguration"); + setDisplayName("Mobile Network"); + } + + @Override + public synchronized void configurationChanged(Map properties) { + // the "reset" toggle is transient; when toggled on, perform a reset and + // then clear the flag so the UI returns to "off" on reload + if ( reset ) { + reset = false; + log.info("Mobile network reset requested via settings"); + try { + executeAction(ACTION_RESET); + } catch ( Exception e ) { + log.warn("Error resetting mobile network: {}", e.getMessage()); + } + } + } + + @Override + public boolean handlesTopic(String topic) { + return InstructionHandler.TOPIC_SYSTEM_CONFIGURE.equals(topic); + } + + @Override + public synchronized InstructionStatus processInstruction(Instruction instruction) { + if ( instruction == null || !handlesTopic(instruction.getTopic()) + || !MOBILE_SERVICE_NAME.equals(instruction.getParameterValue(PARAM_SERVICE)) ) { + return null; + } + String action = instruction.getParameterValue(PARAM_ACTION); + if ( action == null || action.isEmpty() ) { + action = ACTION_STATUS; + } + Map resultParams = new LinkedHashMap<>(2); + InstructionState resultState = InstructionState.Completed; + try { + switch (action.toLowerCase(Locale.ENGLISH)) { + case ACTION_STATUS: + resultParams.put(PARAM_SERVICE_RESULT, currentStatus()); + break; + case ACTION_RESET: + case ACTION_RESTART: + List result = executeAction(action.toLowerCase(Locale.ENGLISH)); + resultParams.put(PARAM_SERVICE_RESULT, result); + break; + default: + resultParams.put(PARAM_MESSAGE, + getMessageSource().getMessage("error.unsupportedAction", + new Object[] { action }, "Unsupported action.", + Locale.getDefault())); + resultState = InstructionState.Declined; + } + } catch ( Exception e ) { + resultParams.put(PARAM_MESSAGE, e.toString()); + resultState = InstructionState.Declined; + } + return InstructionUtils.createStatus(instruction, resultState, Instant.now(), resultParams); + } + + @Override + public String getSettingUid() { + return getUid(); + } + + @Override + public List getSettingSpecifiers() { + final Status status = currentStatus(); + final List result = new ArrayList<>(2); + result.add(new BasicTitleSettingSpecifier("status", statusMessage(status))); + + // Only offer the reset action when a modem is actually present, so nodes + // without a mobile modem do not show a confusing toggle. + if ( status.present ) { + result.add(new BasicToggleSettingSpecifier("reset", Boolean.FALSE, true)); + } + return result; + } + + private String statusMessage(Status status) { + MessageSource messageSource = getMessageSource(); + if ( messageSource == null ) { + return ""; + } + if ( !status.present ) { + return messageSource.getMessage("notSupported.label", null, "No mobile modem available", + Locale.getDefault()); + } + StringBuilder buf = new StringBuilder(); + if ( status.active ) { + buf.append(messageSource.getMessage("active.label", null, "Active", Locale.getDefault())); + } else { + buf.append( + messageSource.getMessage("inactive.label", null, "Inactive", Locale.getDefault())); + } + if ( status.info != null && !status.info.isEmpty() ) { + buf.append("; "); + buf.append(StringUtils.delimitedStringFromCollection(status.info, ", ")); + } + return buf.toString(); + } + + /** + * A mobile connection status. + */ + public static final class Status { + + private final boolean present; + private final boolean active; + private final List info; + + private Status(boolean present, boolean active, List info) { + super(); + this.present = present; + this.active = active; + this.info = info; + } + + /** + * Get the modem presence status. + * + *

+ * This indicates whether a mobile modem is available on the node at all, + * and thus whether a reset can be performed. A client (such as the mobile + * app) can use this to decide whether to offer a reset action, rather than + * attempting a reset that has nothing to act on. + *

+ * + * @return {@literal true} if a mobile modem is present + */ + public boolean isPresent() { + return present; + } + + /** + * Get the active status. + * + * @return {@literal true} if the mobile connection is currently active + */ + public boolean isActive() { + return active; + } + + /** + * Get additional status detail lines (such as operator, access + * technology, or signal), as emitted by the helper script. + * + * @return the status detail lines + */ + public List getInfo() { + return info; + } + } + + private Status currentStatus() { + boolean present = false; + boolean active = false; + List info = new ArrayList<>(4); + try { + List result = executeAction(ACTION_STATUS); + if ( result != null ) { + for ( String line : result ) { + int idx = line.indexOf(':'); + if ( idx < 0 ) { + continue; + } + String key = line.substring(0, idx).trim().toLowerCase(Locale.ENGLISH); + String value = line.substring(idx + 1).trim(); + if ( "present".equals(key) ) { + present = "true".equalsIgnoreCase(value); + } else if ( "active".equals(key) ) { + active = "true".equalsIgnoreCase(value); + } else if ( !value.isEmpty() ) { + info.add(line.trim()); + } + } + } + } catch ( Throwable t ) { + log.warn("Error getting current mobile network status: {}", t.getMessage()); + } + return new Status(present, active, info); + } + + private synchronized List executeAction(final String action, String... args) { + log.debug("Executing mobile action {}", action); + List cmd = new ArrayList<>(8); + cmd.add(command); + cmd.add(CONFIG_SERVICE); + cmd.add(action); + if ( args != null && args.length > 0 ) { + for ( String arg : args ) { + cmd.add(arg); + } + } + List result = new ArrayList<>(8); + ProcessBuilder pb = new ProcessBuilder(cmd); + try { + Process pr = pb.start(); + BufferedReader in = new BufferedReader(new InputStreamReader(pr.getInputStream())); + String line = null; + while ( (line = in.readLine()) != null ) { + result.add(line); + } + + BufferedReader err = new BufferedReader(new InputStreamReader(pr.getErrorStream())); + StringBuilder buf = new StringBuilder(); + line = null; + while ( (line = err.readLine()) != null ) { + if ( buf.length() > 0 ) { + buf.append('\n'); + } + buf.append(line); + } + if ( buf.length() > 0 ) { + log.error("Error executing mobile action {}: {}", action, buf); + } + return result; + } catch ( IOException e ) { + throw new RuntimeException(e); + } + } + + /** + * Set the command to use. + * + * @param command + * the command to set; defaults to {@link #DEFAULT_COMMAND} + */ + public void setCommand(String command) { + this.command = command; + } + + /** + * Set the reset toggle. + * + *

+ * This is a transient setting: when set to {@literal true} a mobile network + * reset is performed and the value is reset to {@literal false}. + *

+ * + * @param reset + * {@literal true} to trigger a reset + */ + public void setReset(boolean reset) { + this.reset = reset; + } +} diff --git a/net.solarnetwork.node.setup.mobile/src/net/solarnetwork/node/setup/mobile/MobileConfiguration.properties b/net.solarnetwork.node.setup.mobile/src/net/solarnetwork/node/setup/mobile/MobileConfiguration.properties new file mode 100644 index 000000000..eb7f76751 --- /dev/null +++ b/net.solarnetwork.node.setup.mobile/src/net/solarnetwork/node/setup/mobile/MobileConfiguration.properties @@ -0,0 +1,18 @@ +title = Mobile Network +desc = Configure and reset the node mobile (cellular/4G) network connection. + +command.key = Command +command.desc = The solarcfg system command to execute. + +status.key = Status +status.desc = The current mobile connection status and associated detail. + +reset.key = Reset Connection +reset.desc = Toggle on to reset the mobile (4G) connection. The toggle returns to off once the \ + reset has been requested. + +active.label = Active +inactive.label = Inactive +notSupported.label = No mobile modem available + +error.unsupportedAction = Unsupported mobile action ''{0}''. Supported actions are 'status', 'reset', and 'restart'. diff --git a/net.solarnetwork.node.setup.wifi/README.md b/net.solarnetwork.node.setup.wifi/README.md index 1c424bc1c..d0dfd5dac 100644 --- a/net.solarnetwork.node.setup.wifi/README.md +++ b/net.solarnetwork.node.setup.wifi/README.md @@ -62,4 +62,4 @@ parameter is not provided, it will remain unchanged from its current value. | `ssid` | The name of the WiFi network to connect to. | | `password` | The WiFi password to use. | -[sn-wifi]: https://github.com/SolarNetworkFoundation/solarnetwork-ops/tree/master/packages/wifi/debian +[sn-wifi]: https://github.com/SolarNetwork/solarnode-os-packages/tree/master/wifi/debian