<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[Technically Ranting]]></title><description><![CDATA[Unstructured thoughts on software development and technology.]]></description><link>https://benjuan26.com/blog/</link><image><url>https://benjuan26.com/blog/favicon.png</url><title>Technically Ranting</title><link>https://benjuan26.com/blog/</link></image><generator>Ghost 5.4</generator><lastBuildDate>Sun, 08 Mar 2026 07:26:04 GMT</lastBuildDate><atom:link href="https://benjuan26.com/blog/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[Building a smart Elite Dangerous control panel with Go and Arduino]]></title><description><![CDATA[There's only one thing missing when I’m cruising around like Han Solo in my Krait Phantom: toggle switches, and lots of them.]]></description><link>https://benjuan26.com/blog/building-a-smart-elite-dangerous-control-panel/</link><guid isPermaLink="false">62daf9b6ee7a84579d9ee342</guid><dc:creator><![CDATA[Benjamin Schubert]]></dc:creator><pubDate>Sun, 07 Jul 2019 02:59:55 GMT</pubDate><media:content url="https://benjuan26.com/blog/content/images/2022/07/359320_screenshots_20190131233644_1-1.jpg" medium="image"/><content:encoded><![CDATA[<img src="https://benjuan26.com/blog/content/images/2022/07/359320_screenshots_20190131233644_1-1.jpg" alt="Building a smart Elite Dangerous control panel with Go and&#xA0;Arduino"><p><a href="https://www.elitedangerous.com/">Elite Dangerous</a> is a fantastic space sim with <a href="https://steamcommunity.com/id/benjuan26/screenshots/?appid=359320&amp;sort=newestfirst&amp;browsefilter=myfiles&amp;view=imagewall">gorgeous views</a>, a dynamic trade system, intuitive flight controls, and a completely open world. There&apos;s only one thing missing when I&#x2019;m cruising around like Han Solo in my Krait Phantom: toggle switches, and lots of them.</p><p>That&#x2019;s why I decided to create a custom control panel. I want to be able to flip a switch to lower my landing gear, turn my running lights on and off, or enable night vision.</p><p>This alone is nothing new. Probably hundreds of people have created some amazing hardware for Elite Dangerous, but I want to make something I haven&apos;t seen before: a set of toggle switches that are reliably in sync with the game.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://benjuan26.com/blog/content/images/2022/07/switches.png" class="kg-image" alt="Building a smart Elite Dangerous control panel with Go and&#xA0;Arduino" loading="lazy" width="1950" height="1300" srcset="https://benjuan26.com/blog/content/images/size/w600/2022/07/switches.png 600w, https://benjuan26.com/blog/content/images/size/w1000/2022/07/switches.png 1000w, https://benjuan26.com/blog/content/images/size/w1600/2022/07/switches.png 1600w, https://benjuan26.com/blog/content/images/2022/07/switches.png 1950w" sizes="(min-width: 720px) 720px"><figcaption>Now this is more like it.</figcaption></figure><h3 id="the-problem-with-toggle-switches">The Problem with Toggle Switches</h3><p>Most actions in Elite Dangerous are <em>momentary</em>: firing weapons, moving around the interface, opening the maps, engaging the FSD, etc. These are simple to use with any hardware because it&#x2019;s a simple button push.</p><p>There are many actions, however, that are <em>toggled</em>: lights, landing gear, cargo scoop, hardpoints, and so on. Frontier Developments were kind enough to provide a &#x201C;hold mode&#x201D; for some of these, such as cargo scoop and silent running, where they&#x2019;re only engaged when the button is held down. If this were the case for all of these controls there would be no need for anything custom; having the switch in the on position would just hold the button down. Most of them, however, can only be enabled by toggling despite <a href="https://forums.frontier.co.uk/threads/suggestion-more-controls-with-hold-toggle-option.287498/" rel="noopener">multiple</a> <a href="https://forums.frontier.co.uk/threads/landing-gear-button-mode-hold.422033/" rel="noopener">requests</a> <a href="https://steamcommunity.com/app/359320/discussions/0/405691491119627040/" rel="noopener">to the</a> <a href="https://forums.frontier.co.uk/threads/controls-landing-gear-hold-option-apart-of-toggle.472555/" rel="noopener">dev team</a>, so I need to create my own &#x201C;hold mode&#x201D;.</p><h3 id="extracting-data-from-the-game">Extracting Data from the Game</h3><p>I haven&#x2019;t talked about the design of the control panel yet, but it&#x2019;s safe to say that since I&#x2019;m designing it, I&#x2019;ll be aware and in control of its state. Since Elite Dangerous is the &#x201C;unknown&#x201D; here, that&#x2019;s where the challenge will lie in keeping the two in sync.</p><p>The good news is, Frontier Developments has provided a way to get read-only information from the game: <a href="http://hosting.zaonce.net/community/journal/v23/Journal_Manual_v23.pdf" rel="noopener">the journal files and the status file</a>.</p><p>The journal files contain an absolute wealth of information about events occurring in the game, such as docking, entering supercruise, refueling, scanning bodies, and many more. I want to focus on the status file because it contains the state of nearly everything that can be toggled. It&#x2019;s a JSON file that&#x2019;s written every few seconds as the data changes. This is what the contents typically look like:</p><pre><code class="language-json">{
  &quot;timestamp&quot;: &quot;2017-12-07T10:31:37Z&quot;,
  &quot;event&quot;: &quot;Status&quot;,
  &quot;Flags&quot;: 16842765,
  &quot;Pips&quot;: [2,8,2],
  &quot;FireGroup&quot;: 0,
  &quot;Fuel&quot;: {
    &quot;FuelMain&quot;: 15.146626,
    &quot;FuelReservoir&quot;: 0.382796
  },
  &quot;GuiFocus&quot;: 5
}</code></pre><p>This looks like a lot of pretty useful information, but it contains even more data than meets the eye: each bit of the value of the <code>Flags</code> field represents the state of something. Here is the complete list, from LSB to MSB, as of the time of this writing:</p><pre><code>0  Docked (on a landing pad)
1  Landed (on planet surface)
2  Landing Gear Down
3  Shields Up
4  Supercruise
5  FlightAssist Off
6  Hardpoints Deployed
7  In Wing
8  LightsOn
9  Cargo Scoop Deployed
10 Silent Running
11 Scooping Fuel
12 Srv Handbrake
13 Srv using Turret view
14 Srv Turret retracted (close to ship)
15 Srv DriveAssist
16 Fsd MassLocked
17 Fsd Charging
18 Fsd Cooldown
19 Low Fuel ( &lt; 25% )
20 Over Heating ( &gt; 100% )
21 Has Lat Long
22 IsInDanger
23 Being Interdicted
24 In MainShip
25 In Fighter
26 In SRV
27 Hud in Analysis mode
28 Night Vision</code></pre><p>Now we&#x2019;re talking! Night vision; lights; landing gear; this is the stuff I&apos;m after. I can send the flags value to the device and have it figure out what it needs to do to get the game into the desired state.</p><h1 id="designing-the-control-panel">Designing the Control Panel</h1><p>Before I worry about how to send the data to the control panel, I&apos;m going to get the device ready to receive it. I&apos;ve decided to use the <a href="https://www.pjrc.com/store/teensy32.html">Teensy 3.2</a>. It&apos;s a fantastic Arduino-compatible board that can present itself as pretty much any USB device: including, in this case, a joystick and a serial device. The joystick part is obvious; the serial device is so that it can receive the game information I&apos;m going to send to it.</p><p>Teensy also has a great community. The forum is extremely helpful for troubleshooting and seeing what others have accomplished with the hardware. <a href="https://forum.pjrc.com/members/632-PaulStoffregen">Paul Stoffregen</a>, the mastermind behind the Teensy, is very active in the community, and <a href="https://forum.pjrc.com/threads/26579-Disable-USB-Mouse-Joystick-From-Keyboard-Mouse-Joystick-Trio-on-Teensy?p=196888#post196888">from my experience</a> is always willing to help out.</p><p>These are my goals for the Arduino system:</p><ul><li>When a switch is flipped, fire a button press.</li><li>If it&apos;s been some time (say, 5 seconds) since a switch has been flipped and the game is still not in the expected state, fire a button press again.</li><li>If the device hasn&apos;t received any game information at all, assume that everything is in sync. This will be useful as a fallback in case the data is not being received for whatever reason.</li></ul><p>I&apos;ll accomplish this using a <code>Toggleswitch</code> struct that&apos;s responsible for its own state. It will check the state of its flag, compare that to its own state, and take the appropriate action.</p><pre><code class="language-cpp">// Global variable to hold the flags from the game state.
#define NO_FLAGS 0
unsigned long currentFlags = NO_FLAGS;

// Each button press will last about 100ms.
#define BUTTON_HOLD_TIME 100

struct Toggleswitch {
  byte currState;            // State of the physical switch.
  byte lastState;            // Last known state of the physical switch.
  byte buttonState;          // Whether the joystick button is pressed.
  byte joyButton;            // Which joystick button the switch belongs to.
  unsigned long releaseTime; // The time after which to stop pressing the button.
  long flag;                 // The flag assigned to this switch.

  // Must have a default constructor in order to use these in an array.
  Toggleswitch() {}

  // Constructor supports an initial state.
  Toggleswitch(byte _state, byte _button, long _flag) {
    currState = _state;
    lastState = _state;
    buttonState = BUTTON_RELEASED;
    joyButton = _button;
    releaseTime = 0L;
    flag = _flag;
  }

  // Is the game in the same state as the switch?
  bool isInSync() {
    // Always treat as in sync if there&apos;s no flag info.
    if (currentFlags == NO_FLAGS) {
      return true;
    }

    bool buttonPressed = lastState == BUTTON_PRESSED;
    bool flagSet = currentFlags &amp; flag;

    // Need to account for the rollover of the millis() value.
    // In that case this value will be huge, which is fine, as it
    // will just try to sync up a little early.
    unsigned long timeSinceReleased = abs(signed(releaseTime - millis()));

	// Always assume things are in sync for a while after the button is pressed
    // to give the game time to catch up.
    return timeSinceReleased &lt; BUTTON_SYNC_TIME || buttonPressed == flagSet;
  }

  // This will be called periodically for the switch to take care of its own updates.
  void update() {
  	// If the switch is flipped, or the game comes out of sync, trigger a button press.
    if (currState != lastState || !isInSync()) {
      lastState = currState;
      buttonState = BUTTON_PRESSED;
      Joystick.button(joyButton, true);
      releaseTime = millis() + BUTTON_HOLD_TIME;
    }
    
    // If the button is pressed and it&apos;s time to release it, release it.
    else if (buttonState == BUTTON_PRESSED &amp;&amp; millis() &gt;= releaseTime) {
      buttonState = BUTTON_RELEASED;
      Joystick.button(joyButton, false);
    }
  }
};</code></pre><h3 id="receiving-the-data">Receiving the Data</h3><p>As I mentioned above, I&apos;m going to use the built-in Serial interface to receive the data from the game. I&apos;m sticking with JSON for a few reasons:</p><ul><li>The data is already in JSON format, so I won&apos;t have to massage it too much.</li><li>I don&apos;t have to create my own method of structuring the data to know when it starts and when it ends.</li><li>It will easily allow for any additional data I choose to include at a later date.</li></ul><p>I&apos;ll use the <a href="https://arduinojson.org/">ArduinoJson</a> library to deserialize the data. It&apos;s fast and very simple to use. I&apos;ll create a function to handle receiving the data over Serial and extracting the data from the JSON.</p><pre><code class="language-cpp">void serialRx() {
  if (!Serial.available()) {
  	return;
  }

  DynamicJsonBuffer jsonBuffer(512);
  JsonObject &amp;root = jsonBuffer.parseObject(Serial);
  if (!root.success()) {
    return;
  }

  long flags = root[&quot;Flags&quot;];
  currentFlags = flags;
}</code></pre><h3 id="grabbing-the-input">Grabbing the Input</h3><p>Like many projects like this, I&apos;m using the <a href="https://playground.arduino.cc/Code/Keypad/">Keypad library</a>. It will allow me to create a matrix of switches or buttons where the number of pins required is the sum of the dimensions of the matrix, e.g. 25 buttons requiring only 10 pins using a 5x5 button matrix. It also has <a href="https://en.wikipedia.org/wiki/Switch#Contact_bounce">debouncing </a>built in so I won&apos;t have to worry about that. I won&apos;t go into detail on how to use or wire up the Keypad library as there are a million tutorials out there that already do it; <a href="https://www.instructables.com/id/How-to-Make-a-Custom-Control-Panel-for-Elite-Dange/">this Instructable</a> gave me lots of hints on how to get started.</p><pre><code class="language-cpp">// Get the states of the physical switches and write them to the Toggleswitch objects.
// Assuming for now that all global variables referenced here are declared and initialized.
void updateKeys() {
  if (!kpd.getKeys()) {
    return;
  }

  for (int i = 0; i &lt; LIST_MAX; i++) {
    if (kpd.key[i].kchar == NO_KEY) {
      continue;
    }

    // Turn the key code (e.g. &apos;F&apos;) into a zero-based array index.
    int keyIndex = (int)kpd.key[i].kchar - keyOffset;
    KeyState state = kpd.key[i].kstate;

    if (state == PRESSED) {
      switches[keyIndex].currState = BUTTON_PRESSED;
    } else if (state == RELEASED) {
      switches[keyIndex].currState = BUTTON_RELEASED;
    }
  }
}</code></pre><h3 id="putting-it-all-together">Putting it all Together</h3><p>All that&apos;s missing now is some glue code and some variable initialization. <a href="https://gist.github.com/BenJuan26/c13758fd301f488291bb24037b43c146">I&apos;ve created a Gist</a> with everything covered here, and a more feature-rich version <a href="https://github.com/BenJuan26/EliteArduino/blob/master/EliteArduino.ino">is on GitHub</a> (and it&apos;s still a work in progress).</p><h2 id="making-the-game-data-accessible">Making the Game Data Accessible</h2><p>The Arduino is ready to receive the game data, and I know where the game data is located; now I just need to create something on the host machine that reads in the data, parses it, prepares it to be sent, and sends it to the device. I&apos;ve decided to divide this into two components:</p><ul><li>An application-agnostic API that will allow the data to be consumed easily.</li><li>A <a href="https://docs.microsoft.com/en-us/dotnet/framework/windows-services/introduction-to-windows-service-applications">Windows Service</a> that periodically reads the data from the API and sends it to the device.</li></ul><h3 id="creating-the-api">Creating the API</h3><p>The first thing to do is locate the files. This is well-documented in <a href="http://hosting.zaonce.net/community/journal/v23/Journal_Manual_v23.pdf" rel="noopener">the Journal manual</a>:</p><blockquote>The journal files are written into the user&#x2019;s Saved Games folder, eg, for Windows: C:\Users\User Name\Saved Games\Frontier Developments\Elite Dangerous\</blockquote><p>That folder contains a number of journal files as well as the <code>Status.json</code> in question. It should be as simple as reading the file, decoding the JSON, and extracting the flags I&apos;m interested in.</p><p>I&apos;ll worry about the flags first. Although for this project I&apos;m going to pass the <code>Flags</code> integer right through to the Arduino, I want to make this API accessible to a wide variety of applications, and thus I want the flags to be easier to work with. I need to use the bitwise AND operator to decipher which flags are active. I&#x2019;ll make a mask for each flag.</p><figure class="kg-card kg-code-card"><pre><code class="language-go">const (
    FlagDocked          uint32 = 0x00000001
    FlagLanded          uint32 = 0x00000002
    FlagLandingGearDown uint32 = 0x00000004
    // ... and so on for each flag
)</code></pre><figcaption>These values are straight out of the Journal manual.</figcaption></figure><p>I&#x2019;ll also create a struct that holds boolean values for every flag.</p><pre><code class="language-go">// StatusFlags contains boolean flags describing the player and ship.
type StatusFlags struct {
    Docked             bool
    Landed             bool
    LandingGearDown    bool
    // etc.
}</code></pre><p>Finally, I&apos;ll create the struct that will store the values as they&apos;re read from the JSON file.</p><pre><code class="language-go">// Status represents the current state of the player and ship.
type Status struct {
    Timestamp string      `json:&quot;timestamp&quot;`
    Event     string      `json:&quot;event&quot;`

    // Naming this &apos;RawFlags&apos; since it will only really by used until
    // the values are extracted into the Flags below.
    RawFlags  uint32      `json:&quot;Flags&quot;`

    // The actual flag values will be set by parsing RawFlags.
    // Including the json tag &quot;-&quot; so that it doesn&apos;t get written to JSON later.
    Flags     StatusFlags `json:&quot;-&quot;`

    Pips      [3]int32    `json:&quot;Pips&quot;`
    FireGroup int32       `json:&quot;FireGroup&quot;`
}</code></pre><p>With those out of the way, I&apos;m ready to read the status file. I&apos;ll create a function that will open the file, unmarshal the JSON into the struct, expand the <code>RawFlags</code> into the <code>Flags</code>, and return the resulting <code>Status</code>.</p><pre><code class="language-go">// ExpandFlags parses the RawFlags and sets the Flags values accordingly.
func (status *Status) ExpandFlags() {
    status.Flags.Docked = status.RawFlags&amp;FlagDocked != 0
    status.Flags.Landed = status.RawFlags&amp;FlagLanded != 0
    status.Flags.LandingGearDown = status.RawFlags&amp;FlagLandingGearDown != 0
    // etc.
}

func GetStatus() (*Status, error) {
    // Build the file path from the user&apos;s home directory.
    currentUser, _ := user.Current()
    logPath := filepath.FromSlash(currentUser.HomeDir + &quot;/Saved Games/Frontier Developments/Elite Dangerous&quot;
    statusFilePath := filepath.FromSlash(logPath + &quot;/Status.json&quot;)

    // Open the file.
    statusFile, err := os.Open(statusFilePath)
    if err != nil {
        return nil, err
    }
    defer statusFile.Close()

    // Read the contents of the file.
    statusBytes, err := ioutil.ReadAll(statusFile)
    if err != nil {
        return nil, err
    }

    // Parse the JSON and store the contents in a Status object.
    var status *Status
    err = json.Unmarshal(statusBytes, status)
    if err != nil {
        return nil, err
    }

    // Expand the RawFlags integer into the boolean values in Flags.
    status.ExpandFlags()
    return status, nil
}</code></pre><p>That&apos;ll be enough to get started. With this I&apos;ll be able to periodically read the data written by the game and send it to the control panel. <a href="https://github.com/BenJuan26/elite">I&apos;ve put the API on GitHub</a>.</p><h3 id="finding-the-serial-port">Finding the Serial Port</h3><p>In order to send the data to the device, I&apos;ll need a serial interface for Go. There are a few libraries that can do this. I&apos;ve chosen <a href="https://github.com/tarm/serial">tarm/serial</a> since it&apos;s bare-bones and has just enough functionality to suit my needs. Here&apos;s an excerpt from the example in the readme:</p><pre><code class="language-go">func main() {
    c := &amp;serial.Config{Name: &quot;COM45&quot;, Baud: 115200}
    s, err := serial.OpenPort(c)
    if err != nil {
        log.Fatal(err)
    }

    n, err := s.Write([]byte(&quot;test&quot;))
    if err != nil {
        log.Fatal(err)
    }
}</code></pre><p>The only things I need to provide to the library are the serial port and the Baud rate. Baud rate is relatively trivial as long as the value is agreed on and supported by both devices. Serial port is not so straightforward, since the value is not constant for a given device. If I plug in the Teensy, check which serial port it&apos;s connected to, unplug it, plug it back in, and check the serial port again, the values are not guaranteed to be the same. I need something the can uniquely identify the device and map it to the serial port it&apos;s currently connected with.</p><p>After some research I concluded that Windows&apos; <a href="https://docs.microsoft.com/en-us/windows/desktop/wmisdk/wmi-reference">WMI system</a> would be the best candidate for this. Among its many classes is <code>Win32_SerialPort</code> which contains the field <code>PNPDeviceID</code>. It&apos;s a unique identifier for the device, so it satisfies that requirement. Now I need to determine whether it can be used to get the current serial port for the device.</p><p>A useful tool that&apos;s part of WMI is <em>WQL</em>, which allows for the WMI system to be queried just like an SQL database. WQL can be run directly in PowerShell by running <code>Get-WmiObject -Query &quot;&lt;WQL Query&gt;&quot;</code>. For example, to see all information about all my connected serial devices, I can run:</p><pre><code>Get-WmiObject -Query &quot;SELECT * FROM Win32_SerialPort&quot;</code></pre><p>The output will look something like this:</p><pre><code>__GENUS                     : 2
__CLASS                     : Win32_SerialPort
...
CreationClassName           : Win32_SerialPort
Description                 : Communications Port
DeviceID                    : COM1
...
MaxBaudRate                 : 115200
MaximumInputBufferSize      : 0
MaximumOutputBufferSize     : 0
...
PNPDeviceID                 : USB\VID_4321&amp;PID_0001\1
...</code></pre><p>I can take the <code>PNPDeviceID</code>, store it, and use it to get the <code>DeviceID</code>, like so:</p><pre><code>Get-WmiObject -Query &quot;SELECT DeviceID FROM Win32_SerialPort WHERE PNPDeviceID = &apos;USB\\VID_4321&amp;PID_0001\\1&apos;&quot;</code></pre><p>So yes, indeed, the <code>PNPDeviceID</code> can be used to get the serial port. I&apos;ll use StackExchange&apos;s <a href="https://github.com/StackExchange/wmi">WMI package</a> to run WQL with Go and get the results I need from the system, like so:</p><pre><code class="language-go">const baudRate = 9600

type serialPort struct {
    MaxBaudRate int
    DeviceID    string
}

func getSerialPort(pnp string) (*serial.Port, error) {
    var dst []serialPort

    // WMI needs the backslashes to be escaped.
    escaped := strings.Replace(pnp, &quot;\\&quot;, &quot;\\\\&quot;, -1)
    query := &quot;SELECT DeviceID, MaxBaudRate FROM Win32_SerialPort WHERE PNPDeviceID=&apos;&quot; + escaped + &quot;&apos;&quot;
    client := &amp;wmi.Client{AllowMissingFields: true}
    
    // Put the query results into a slice of serialPort objects.
    err := client.Query(query, &amp;dst)
    if err != nil {
        return nil, fmt.Errorf(&quot;Couldn&apos;t connect to serial port: %s&quot;, err.Error())
    } else if len(dst) &lt; 1 {
        return nil, fmt.Errorf(&quot;Couldn&apos;t find a PNP Device with ID &apos;%s&apos;&quot;, pnp)
    }

	// Open the serial connection.
    conf := &amp;serial.Config{Name: dst[0].DeviceID, Baud: baudRate}
    s, err := serial.OpenPort(conf)
    if err != nil {
        return nil, fmt.Errorf(&quot;Couldn&apos;t open serial port: %s&quot;, err.Error())
    }

    elog.Info(1, fmt.Sprintf(&quot;Connected to serial port %s at baud rate %d&quot;, dst[0].DeviceID, baudRate))
    
    // Everything seems to have worked; return the serial port.
    return s, nil
}</code></pre><h3 id="creating-the-service">Creating the Service</h3><p>Windows Services are part of Go&apos;s standard library, and they provide <a href="https://github.com/golang/sys/tree/master/windows/svc/example">a sample package</a> demonstrating how to get started. I&apos;ll start by copying the whole example repo. They divide the code into a few different files but I&apos;ve combined it all into a <code>service.go</code> file to keep all the service-related code in one place.</p><p>The service compiles to an executable that has a basic set of administrative commands: <code>debug</code>, <code>install</code>, <code>remove</code>, <code>start</code>, <code>stop</code>, <code>pause</code>, and <code>continue</code>. I&apos;m hardly going to touch those commands since they contain all the behaviour necessary to administrate a Windows Service. I will, however, add a new command: <code>configure</code>. That will allow me to interactively perform the WMI commands described above and save the selected preferences in a configuration file that the service will use when it starts up.</p><p>The config will store three things: PNP device ID, Baud rate, and log file path. As with most Go projects of mine, I&apos;ll store the config in a JSON file. In this case it will look something like this:</p><pre><code class="language-json">{
    &quot;pnp_device_id&quot;: &quot;USB\\VID_4321&amp;PID_0001\\1&quot;,
    &quot;baud_rate&quot;: 9600,
    &quot;log_dir&quot;: &quot;C:/Users/SampleUser/Saved Games/Frontier Developments/Elite Dangerous&quot;
}</code></pre><p>The main logic of the service is in the <code>myservice</code> struct in the sample package. I&apos;ll change this to <code>monitorService</code>. The important part is that it implements <code>svc.Handler</code>, and the only requirement for that is implementing the <code>Execute</code> function like below.</p><pre><code class="language-go">type monitorService struct{}

func (m *monitorService) Execute(args []string, r &lt;-chan svc.ChangeRequest, changes chan&lt;- svc.Status) (ssec bool, errno uint32) {
    const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
    changes &lt;- svc.Status{State: svc.StartPending}
    // ...
}</code></pre><p>The sample <code>Execute</code> function uses the concept of a <code>slowtick</code> and a <code>fasttick</code>, where it will switch between beeping every 20 milliseconds and every 10 seconds depending on whether the service is running or paused, respectively. I won&apos;t support the paused state here (simply stopping the service would be more appropriate), but I will use the idea of two different loop intervals: a short interval for when the service is running normally; and a long interval after the device has been disconnected.</p><p>If the device disconnects, I&apos;ll keep querying WMI every 10 seconds or so until I find the device, after which I&apos;ll start the regular loop again.</p><p>Inside the Execute function, I&apos;ll use the API I created to query the status file, and if it&apos;s changed, send it to the device through the configured serial port.</p><pre><code class="language-go">func checkStatusAndUpdate() error {
    s, err = getSerialPort(getPNPDeviceID())
    if err != nil {
        return err
    }

    status, err := elite.GetStatusFromPath(filepath.FromSlash(getLogDir()))
    if err != nil {
        return err
    }

    // Only send the data if it has changed.
    if status.Timestamp != lastStatus.Timestamp {
        infoBytes, err := json.Marshal(status)
        if err != nil {
            return err
        }

        _, err = s.Write(infoBytes)
        if err != nil {
            return err
        }
    }

    return nil
}</code></pre><h3 id="recapping">Recapping</h3><p>Here is what I need to do with the service:</p><ul><li>Clone the sample service.</li><li>Add a new <code>configure</code> command that will step the user through configuration, and save the result to a config file.</li><li>Create my own <code>svc.Handler</code> with an <code>Execute</code> function containing the main logic of the service.</li><li>Watch for changes in the status file and send them to the device through the serial port.</li></ul><p>The resulting code is, as usual, <a href="https://github.com/BenJuan26/edca/blob/master/monitor.go">on GitHub</a>, where I&apos;ve called it Elite Dangerous Cockpit Agent, or EDCA.</p><h2 id="what-s-next">What&apos;s Next?</h2><p>If you look at the GitHub links, you&apos;ll notice some things in the code that I didn&apos;t mention here, like the API having a <code>GetStarSystem</code> function, or the Arduino code referencing pips and shift registers. What I eventually want for this control panel is for it to receive much more data than just the flags. That&apos;s still a work in progress, so if I ever finish it, I&apos;ll do another write-up.</p>]]></content:encoded></item><item><title><![CDATA[Writing a simple task Applet for Cinnamon Desktop]]></title><description><![CDATA[Not too long ago I installed Arch Linux. The laws of the universe dictate that I shout it from the mountain tops, so here we are.]]></description><link>https://benjuan26.com/blog/writing-a-simple-task-applet-for-cinnamon-desktop/</link><guid isPermaLink="false">62daf9b6ee7a84579d9ee343</guid><category><![CDATA[Linux]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Benjamin Schubert]]></dc:creator><pubDate>Tue, 25 Jun 2019 13:30:52 GMT</pubDate><content:encoded><![CDATA[<p>Not too long ago I installed Arch Linux. The laws of the universe dictate that I shout it from the mountain tops, so here we are. When I was deciding on which desktop environment to use, I wanted to try something new; I&#x2019;ve used Ubuntu quite a bit so I wanted to steer away from Unity and Gnome. Enter <a href="https://github.com/linuxmint/Cinnamon" rel="noopener">Cinnamon</a>. It&#x2019;s reasonably popular and I&#x2019;ve heard good things about it, so I thought I&#x2019;d give it a try.</p><p>I&#x2019;ve never developed for Gnome or Cinnamon before, so creating an Applet was new territory for me. It was an interesting journey, so I&#x2019;ll share my experience here in the hopes of helping others looking to do the same.</p><h3 id="a-basic-use-case">A Basic Use Case</h3><p>When I&#x2019;m using my desktop PC at home, I frequently need to change the audio output between my speakers and my headphones. On Windows this is rather trivial; I can click the volume icon in the system tray, click the device drop-down, and select the output device I want.</p><p>To do this by keyboard shortcut I use <a href="https://soundswitch.aaflalo.me/" rel="noopener">SoundSwitch,</a> which is also useful because I can tell it which devices I want to have in the rotation, removing clutter such as digital-only or virtual output devices. That means I can quickly switch between my speakers and my headphones from any application by using the keyboard shortcut, <em>Ctrl+Alt+F11</em>.</p><p>As I&#x2019;ll explain in the next section, on Linux it&#x2019;s not so simple. On the bright side, that means I get to create something! My initial goal was simple: Switch the audio output device by using a keyboard shortcut.</p><h3 id="exploring-a-solution">Exploring a Solution</h3><p>Why did I need to create something custom in the first place? In the case of my sound card, the speakers and headphones show up as two <em>sinks </em>on a single <em>device</em>. The active sink is triggered by physically plugging and unplugging the headphones, and, in Gnome and Cinnamon at least, it&#x2019;s not possible to force one or the other to be active.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://benjuan26.com/blog/content/images/2022/07/1.png" class="kg-image" alt loading="lazy" width="800" height="627" srcset="https://benjuan26.com/blog/content/images/size/w600/2022/07/1.png 600w, https://benjuan26.com/blog/content/images/2022/07/1.png 800w" sizes="(min-width: 720px) 720px"><figcaption>Cinnamon&#x2019;s sound settings don&#x2019;t have an option to change the active sink.</figcaption></figure><p>More fine-grained control can be gained by using <code>alsamixer</code>. It provides a mixer interface for the devices and sinks, as well as some low-level sound settings. After selecting my sound card with F6, I&#x2019;m presented with the mixer.</p><figure class="kg-card kg-image-card"><img src="https://benjuan26.com/blog/content/images/2022/07/2.png" class="kg-image" alt loading="lazy" width="653" height="485" srcset="https://benjuan26.com/blog/content/images/size/w600/2022/07/2.png 600w, https://benjuan26.com/blog/content/images/2022/07/2.png 653w"></figure><p><code>Headphones</code> and <code>Front</code> are the sinks that I&#x2019;m after. After flipping the <code>Auto-Mute Mode</code><em> </em>setting to <code>Disabled</code>, I can switch the outputs by setting one slider to 0 and the other to 100. There&#x2019;s only one problem here: <em>alsamixer</em> is an interactive terminal application, and thus not suitable for scripting.</p><p>Thankfully, <code>amixer</code> provides similar functionality but with non-interactive commands.</p><pre><code>amixer -c0 set &quot;Auto-Mute Mode&quot; Disabled # Disable auto-muting
amixer -c0 set &apos;old-device-name&apos; 0%      # Mute the old device
amixer -c0 set &apos;new-device-name&apos; 100%    # Enable the new device</code></pre><p>Additionally, I need to use <code>pactl</code> to tell the OS which sink we&#x2019;re actively using.</p><pre><code>pactl set-sink-port 0 &apos;sink-name&apos;        # Set the desired sink</code></pre><p>So far, so good. If I run these with the proper device and sink names, I can switch the output to the device I want.</p><h3 id="scripting-it-up">Scripting It Up</h3><p>To get one step closer to my goal, I needed to have a script that would run the above commands in such a way that the output device would switch back and forth. I also opted to use <code>notify-send</code><em> </em>to display a transient desktop notification, letting me know that the output was changed successfully. This is what I came up with:</p><pre><code class="language-bash">#!/bin/bash
CURRENT_PROFILE=$(pactl list sinks | grep &quot;Active Port&quot; | cut -d &apos; &apos; -f 3-)
NOTIFICATION_DURATION_MS=2000

notify() {
	notify-send --hint=int:transient:1 -t $NOTIFICATION_DURATION_MS &quot;Sound Switch&quot; &quot;$1&quot;
}

amixer -c0 set &quot;Auto-Mute Mode&quot; Disabled

if [ &quot;$CURRENT_PROFILE&quot; = &quot;analog-output-lineout&quot; ]; then
	pactl set-sink-port 0 &quot;analog-output-headphones&quot;
	amixer -c0 set Front 0% &gt; /dev/null
	amixer -c0 set Headphone 100% &gt; /dev/null
	notify &quot;Sound output switched to speakers&quot;
else
	pactl set-sink-port 0 &quot;analog-output-lineout&quot;
	amixer -c0 set Headphone 0% &gt; /dev/null
	amixer -c0 set Front 100% &gt; /dev/null
	notify &quot;Sound output switched to headphones&quot;
fi</code></pre><p>There we have it! Every time this script is run, it will switch between headphones and speakers.</p><h3 id="keyboard-shortcut">Keyboard Shortcut</h3><p>Now that I had a script for my solution, I needed to tell Cinnamon to run it when the desired keyboard shortcut was pressed. Thankfully this isn&#x2019;t hard. The <em>Keyboard </em>menu has a tab for adding custom shortcuts. After setting the desired key combination, I pointed it to the script I wrote.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://benjuan26.com/blog/content/images/2022/07/3.png" class="kg-image" alt loading="lazy" width="798" height="597" srcset="https://benjuan26.com/blog/content/images/size/w600/2022/07/3.png 600w, https://benjuan26.com/blog/content/images/2022/07/3.png 798w" sizes="(min-width: 720px) 720px"><figcaption>I wanted to make the shortcut Ctrl+Alt+F11 like SoundSwitch, but that&#x2019;s reserved for TTY, so Ctrl+F11 did the trick.</figcaption></figure><p>That seemed to meet my requirements! Pressing <em>Ctrl+F11</em> will call the script, which will switch the audio device and send a nice little notification.</p><h3 id="taking-it-up-a-notch">Taking it Up a Notch</h3><p>I was actually using the above script in my workflow for a while, but it felt like something was missing. I wanted to have an indicator of which device was currently selected, as well as the ability to switch devices with a mouse click. The system tray seemed to be the best fit for this. For a while I tried searching for tutorials on writing applications that sit in the system tray on Cinnamon, with surprisingly little results. Eventually I realized why: system tray applications on Cinnamon have a name: <a href="https://cinnamon-spices.linuxmint.com/applets" rel="noopener">Applets</a>.</p><h3 id="writing-our-first-applet">Writing Our First Applet</h3><p>As a user, I&#x2019;ve been really happy with Cinnamon so far. It&#x2019;s simple, beautiful, and customizable. As a developer, on the other hand, I&#x2019;ve been extremely frustrated with it.</p><p>Cinnamon is a fork of <a href="https://www.gnome.org/" rel="noopener">Gnome</a>. Because of this, it inherits a lot of functionality from it. This is a good thing: Gnome is very well documented (with some <a href="https://gitlab.gnome.org/GNOME/gjs/issues/250" rel="noopener">notable exceptions</a>) and popular, which makes it relatively welcoming to newcomers like myself.</p><p>The problem is that Cinnamon&#x2019;s diversions from Gnome are <em>not</em> well documented and were, at least for me, very hard to track down, or in some cases non-existent.</p><p>They do provide a <a href="http://developer.linuxmint.com/reference/git/cinnamon-tutorials/write-applet.html" rel="noopener">quick tutorial on how to write a simple Applet</a>. I used this as a jumping off point to start tinkering. Even more helpful was <a href="http://billauer.co.il/blog/2018/12/writing-cinnamon-applet/" rel="noopener">this blog post by Eli Billauer</a>. With these resources I was able to make an Applet that, when clicked in the system tray, would run the same commands as our script above. The two necessary files are <code>metadata.json</code> and <code>applet.js</code>. Here is what my first <code>applet.js</code> looked like:</p><pre><code class="language-javascript">const Applet = imports.ui.applet;
const GLib = imports.gi.GLib;

function run(cmd) {
    try {
        let [result, stdout, stderr] = GLib.spawn_command_line_sync(cmd);
        if (stdout !== null) {
            return stdout.toString();
        }
    } catch (error) {
        global.logError(error.message);
    }
}

function getCurrentDevice() {
    const output = run(&apos;pactl list sinks&apos;);
    const lines = output.split(&apos;\n&apos;);
    for (let i = 0; i &lt; lines.length; i += 1) {
        const line = lines[i];
        if (line.includes(&apos;Active Port&apos;)) {
            const columns = line.split(&apos; &apos;);
            return columns[2];
        }
    }

    throw new Error(&apos;Couldn\&apos;t find current audio device&apos;);
}

function switchDevices() {
    const device = getCurrentDevice();

    // Defaults
    let targetDevice = &apos;analog-output-headphones&apos;;
    let deviceToDisable = &apos;Front&apos;;
    let deviceToEnable = &apos;Headphone&apos;;
    let deviceNotifyName = &apos;speakers&apos;;

    if (device === &apos;analog-output-headphones&apos;) {
        targetDevice = &apos;analog-output-lineout&apos;;
        deviceToDisable = &apos;Headphone&apos;;
        deviceToEnable = &apos;Front&apos;;
        deviceNotifyName = &apos;headphones&apos;;
    }

    GLib.spawn_command_line_async(`pactl set-sink-port 0 ${targetDevice}`);
    GLib.spawn_command_line_async(`amixer -c0 set ${deviceToDisable} 0%`);
    GLib.spawn_command_line_async(`amixer -c0 set ${deviceToEnable} 100%`);
    GLib.spawn_command_line_async(`notify-send --hint=int:transient:1 -t 2000 &quot;Sound Switch&quot; &quot;Sound output switched to ${deviceNotifyName}&quot;`);
}

class MyApplet extends Applet.IconApplet {
    constructor(orientation, panelHeight, instanceId) {
        super(orientation, panelHeight, instanceId);
        
        this.updateIcon = this.updateIcon.bind(this);
        this.updateIcon();
        this.set_applet_tooltip(_(&apos;Click to switch audio devices&apos;));
    }

    updateIcon() {
        const currentDevice = getCurrentDevice();
        if (currentDevice === &apos;analog-output-lineout&apos;) {
            this.set_applet_icon_symbolic_name(&apos;audio-headphones&apos;);
        } else {
            this.set_applet_icon_symbolic_name(&apos;multimedia-volume-control&apos;);
        }
    }

    on_applet_clicked() {
        global.log(&apos;Switching audio devices&apos;);
        switchDevices();
        this.updateIcon();
    }
}

function main(metadata, orientation, panelHeight, instanceId) {
    return new MyApplet(orientation, panelHeight, instanceId);
}</code></pre><p>When the Applet is clicked, the <code>on_applet_clicked()</code> function is called. From there I call the helper functions <code>switchDevices()</code> and <code>updateIcon()</code>.</p><p>Where Cinnamon&#x2019;s basic Applet tutorial started to break down for me was when I needed to capture the output of a system command, namely <code>pactl</code><em>, </em>to get the current output device. Cinnamon&#x2019;s <code>util</code><em> </em>library doesn&#x2019;t provide such a thing, so I had to look at the underlying implementation and use <code>GLib</code><em> </em>commands directly.</p><blockquote><strong>Note</strong>: using GLib&#x2019;s <code>spawn_command_line_sync()</code> is one of a few functions that will cause a warning to appear when you try to install your applet, claiming that it could cause issues with Cinnamon. I found this to be accurate when some commands I was running went longer than expected, causing the entire desktop to hang.</blockquote><p>You can also see that I&#x2019;m setting the icon based on the current output device. As mentioned in Cinnamon&#x2019;s tutorial, the icon name references installed icons in <code>/usr/share/icons</code>. I found the icons <code>audio-headphones</code><em> </em>and <code>multimedia-volume-control</code>to be good analogs for headphones and speakers respectively. Note that I&#x2019;m using <a href="https://wiki.gnome.org/Design/OS/SymbolicIcons" rel="noopener">symbolic icons</a> because system tray icons should be simple and monochrome.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://benjuan26.com/blog/content/images/2022/07/4.png" class="kg-image" alt loading="lazy" width="802" height="634" srcset="https://benjuan26.com/blog/content/images/size/w600/2022/07/4.png 600w, https://benjuan26.com/blog/content/images/2022/07/4.png 802w" sizes="(min-width: 720px) 720px"><figcaption>The Applet can be installed from the Applets menu, after which it will appear in the system tray.</figcaption></figure><p>All in all, the above code is not too different from Cinnamon&#x2019;s example Applet. It&#x2019;s an <code>IconApplet</code> that performs an action when clicked.</p><p>We&#x2019;re done, right?</p><h3 id="applets-and-signals">Applets and Signals</h3><p>At this point I was pretty happy with my first Applet. It did a thing when clicked, it was snappy, it had pretty icons that updated properly. There were just two problems:</p><ol><li>The Applet itself isn&#x2019;t triggered by a keyboard shortcut.</li><li>If I leave the old script hooked up to the keyboard shortcut, the Applet icon will get out of sync.</li></ol><p>Now, I won&#x2019;t tell you how much time I spent trying to figure out how to get Applets to respond to global keyboard shortcuts. Suffice it to say I tried a lot of different ways and was extremely frustrated by the time I finally came up with a solution. The <em>Eureka! </em>moment happened when I was scouring the code of the built-in Sound Applet. I was looking there because the volume icon changes according to the current volume setting; it must have a connection to the system, keyboard or otherwise! I came across these lines of code:</p><pre><code class="language-javascript">this._sound_settings = new Gio.Settings({ schema_id: CINNAMON_DESKTOP_SOUNDS });
this._sound_settings.connect(&quot;changed::&quot; + MAXIMUM_VOLUME_KEY, Lang.bind(this, this._on_sound_settings_change));</code></pre><p>Let&#x2019;s break this down. Arguably the most important part of these lines is the <code>connect</code> function call. <a href="https://developer.gnome.org/gtkmm-tutorial/stable/sec-signals-overview.html.en" rel="noopener">Gnome and GTK use <em>Signals</em></a><em> </em>to allow any object to trigger events on any other object, without the emitter needing to know anything about the receiver. I&#x2019;ve done <a href="https://github.com/BenJuan26/OpenSkyStacker" rel="noopener">some work with Qt</a> so this concept was familiar to me. You&#x2019;ll notice in Gnome and Cinnamon documentation that many objects have a list of Signals just like they have a list of Properties and Functions. That&#x2019;s what we&#x2019;re going to take advantage of.</p><p>What is the Sound Applet connecting to? <a href="https://developer.gnome.org/GSettings/" rel="noopener">GSettings</a>. <code>Gio.Settings</code> allows me to connect my<em> </em>Applet to <a href="https://lazka.github.io/pgi-docs/Gio-2.0/classes/Settings.html#Gio.Settings.signals.changed" rel="noopener">the </a><code><a href="https://lazka.github.io/pgi-docs/Gio-2.0/classes/Settings.html#Gio.Settings.signals.changed" rel="noopener">changed</a></code><a href="https://lazka.github.io/pgi-docs/Gio-2.0/classes/Settings.html#Gio.Settings.signals.changed" rel="noopener"><em> </em>signal</a> of a GSettings key. This is the connection I needed!</p><p>This was my new plan: Connect the Applet to a GSettings key of my own creation and update the device and icon when the setting changes. Let&#x2019;s do it!</p><h3 id="creating-a-gsettings-schema">Creating A GSettings Schema</h3><p>In order to compile my GSettings schema, I needed to create a <code>.gschema.xml</code> file in <code>/usr/share/glib-2.0/schemas</code>. Here&#x2019;s what mine looks like:</p><pre><code class="language-xml">&lt;schemalist&gt;
  &lt;schema id=&quot;com.benjuan26.soundswitch&quot; path=&quot;/com/benjuan26/soundswitch/&quot;&gt;
    &lt;key type=&quot;s&quot; name=&quot;device&quot;&gt;
      &lt;default&gt;&quot;analog-output-lineout&quot;&lt;/default&gt;
      &lt;summary&gt;The current audio output device&lt;/summary&gt;
      &lt;description/&gt;
    &lt;/key&gt;
  &lt;/schema&gt;
&lt;/schemalist&gt;</code></pre><p>The <code>id</code> and <code>path</code> properties of the <code>schema</code> should be unique. The convention for <code>id</code> is to use your reversed domain name followed by a unique identifier for your schema. The same content can be used for <code>path</code>, except using slashes as separators. Note that <code>path</code> <em>must </em>end in a trailing slash.</p><p>I&#x2019;ve specified the setting called <code>device</code> of type string (hence the <code>&quot;s&quot;</code>). Once that was in place, I needed to tell GLib to re-compile the schemas:</p><pre><code>glib-compile-schemas /usr/share/glib-2.0/schemas</code></pre><p>Once that was done I could use the setting in my Applet.</p><h3 id="refactoring-the-applet-to-use-gsettings">Refactoring the Applet to Use GSettings</h3><p>Now that I had set up GSettings, I could refactor my Applet. I connected it to a <code>Gio.Settings</code> object pointing to my newly created schema. When the Applet is clicked, instead of running the commands explicitly, it will simply flip the settings value and let the event flow pick it up. That will ensure that, no matter where the change is made, it will always be picked up by the Applet.</p><pre><code class="language-javascript">const Applet = imports.ui.applet;
const GLib = imports.gi.GLib;
const Gio = imports.gi.Gio;

const settingsSchemaId = &apos;com.benjuan26.soundswitch&apos;;
const settingsKey = &apos;device&apos;;
const notificationDuration = &apos;2000&apos;;
const notificationTitle = &apos;Sound Switch&apos;;

function oppositeDevice(currentDevice) {
    if (currentDevice === &apos;analog-output-headphones&apos;) {
        return &apos;analog-output-lineout&apos;;
    }

    return &apos;analog-output-headphones&apos;;
}

function setDevice(device) {
    // Defaults
    let deviceToDisable = &apos;Front&apos;;
    let deviceToEnable = &apos;Headphone&apos;;
    let deviceNotifyName = &apos;speakers&apos;;

    if (device === &apos;analog-output-lineout&apos;) {
        deviceToDisable = &apos;Headphone&apos;;
        deviceToEnable = &apos;Front&apos;;
        deviceNotifyName = &apos;headphones&apos;;
    }

    GLib.spawn_command_line_async(`pactl set-sink-port 0 ${device}`);
    GLib.spawn_command_line_async(`amixer -c0 set &quot;Auto-Mute Mode&quot; Disabled`);
    GLib.spawn_command_line_async(`amixer -c0 set ${deviceToDisable} 0%`);
    GLib.spawn_command_line_async(`amixer -c0 set ${deviceToEnable} 100%`);
    GLib.spawn_command_line_async(`notify-send --hint=int:transient:1 -t ${notificationDuration} &quot;${notificationTitle}&quot; &quot;Sound output switched to ${deviceNotifyName}&quot;`);
}

class MyApplet extends Applet.IconApplet {
    constructor(orientation, panelHeight, instanceId) {
        super(orientation, panelHeight, instanceId);

        this._settings = new Gio.Settings({ schema_id: settingsSchemaId });
        const device = this._settings.get_string(settingsKey);

        this.updateIcon = this.updateIcon.bind(this);
        this.updateIcon(device);
        this.set_applet_tooltip(_(&apos;Click to switch audio devices&apos;));

        this.settingsConnectId = this._settings.connect(`changed::${settingsKey}`, () =&gt; { this.onSettingsChanged(); });
    }

    updateIcon(device) {
        if (device === &apos;analog-output-lineout&apos;) {
            this.set_applet_icon_symbolic_name(&apos;audio-headphones&apos;);
        } else {
            this.set_applet_icon_symbolic_name(&apos;multimedia-volume-control&apos;);
        }
    }

    on_applet_clicked() {
        global.log(&apos;Sound switch clicked&apos;);
        const device = this._settings.get_string(&apos;device&apos;);
        this._settings.set_string(settingsKey, oppositeDevice(device));
    }

    onSettingsChanged() {
        const device = this._settings.get_string(&apos;device&apos;);
        global.log(`Updating sound output device to ${device} based on settings change`);

        setDevice(device);
        this.updateIcon(device);
    }
}

function main(metadata, orientation, panelHeight, instanceId) {
    return new MyApplet(orientation, panelHeight, instanceId);
}</code></pre><p>Now the Applet does the same thing it did before, except now it does it by receiving a signal from our GSettings object. So, what&#x2019;s left?</p><h3 id="activating-the-applet-by-keyboard-shortcut">Activating the Applet by Keyboard Shortcut</h3><p>All I had left to do was to use a keyboard shortcut to trigger the settings change. Since I already had <em>Ctrl+F11 </em>wired to a bash script, I simply modified the script to use the <code>gsettings</code> CLI to make the change.</p><pre><code class="language-bash">#!/bin/bash
SCHEMA=&quot;com.benjuan26.soundswitch&quot;
KEY=device

CURRENT_DEVICE=$(gsettings get ${SCHEMA} ${KEY})

if [ &quot;$CURRENT_DEVICE&quot; = &quot;&apos;analog-output-lineout&apos;&quot; ]; then
	echo &quot;first one&quot;
	gsettings set ${SCHEMA} ${KEY} &quot;analog-output-headphones&quot;
else
	echo &quot;second one&quot;
	gsettings set ${SCHEMA} ${KEY} &quot;analog-output-lineout&quot;
fi</code></pre><p>That&#x2019;s it! Now I can switch my audio output device by either clicking the icon in the system tray or using the key combination. In both cases an icon will be updated and a notification will be shown. Mission accomplished!</p><p>The complete code <a href="https://github.com/BenJuan26/sound-switch-applet/tree/master" rel="noopener">can be found on GitHub.</a></p>]]></content:encoded></item></channel></rss>