Bloggity Blog

So I got a payphone…

// TODO: write this post

DJI Ronin-SC reverse engineering

TL;DR:

This is an attempt at reverse-engineering the DJI Ronin SC camera gimbal with the following goals:

  • using the DJI focus motor separately from the gimbal,
  • controlling the gimbal using custom software,
  • using the gimbal on a jib/crane-like setup,
  • fixing the (many) annoyances and limitations of the official DJI app.

This blog post serves both as my personal notepad and as a starting point for anyone else going down the same path in the future (perhaps with a different DJI device). As the work is still in progress, the post will be updated occasionally.

Part 1: Physical connections

Since this is a rather expensive gadget, I’d like to avoid taking it apart as much as possible. Thankfully, the FCC publishes independent test reports of all RF-emitting gadgets sold in the US, including photos of the wiring and PCBs: https://fccid.io/2ANDR-R181902. Since many of the same accessories fit the Ronin S as well, its FCC report is also quite useful: https://fccid.io/2ANDR-RS11804. If you see photos of components on a blue mat in this article, I’ve likely clipped them from these.

Battery grip mount

The gimbal is split into two main parts – the battery grip and the gimbal head+control unit. Connecting the two is a set of 6 flat contacts on the grip with corresponding pogo pins on the head unit. Presumably, these carry only power, but I have not bothered confirming this.

Side connectors

The gimbal has two sets of side accessory connectors, this time with 8 flat pads on the head and corresponding pogo pins on the accessories. Detaching them from the body by removing the 4 hex screws holding them in place reveals a conveniently labeled PCB and rather annoyingly tiny internal header.

A few minutes of poking with the smallest multimeter probe I could find reveals the following correspondence:

//TODO: include pinout

Pages: 1 2 3

QS-1205CME-3A

TL;DR: adjustable by default. If you want to lock it to a specific voltage, cut the line above ADJ and solder across the pads next to the voltage you want.

Source: https://www.qskj.cc/shop/product/1941621-mini-dc-dc-buck-step-down-module-4-24v-12v-24v-to-5v-3a-power-module-qs-1205cme-3a-79148

Wireguard point-to-point setup

Let’s say you have two servers: Cabbage and BEETroot (naming your servers after vegetables is normal, right?). Cabbage hosts a database and some other services and BEETroot hosts your application. You want them to be able to connect to each other securely, without having to deal with securing every one of the services to go over the Internet.

This guide was written for Ubuntu Server 18.04, but should be valid for any Linux system (aside from the installation steps).

Installing Wireguard

sudo add-apt-repository ppa:wireguard/wireguard
sudo apt-get install wireguard

Generating keys

On each server, a private key needs to be generated and a public key derived from it. For convenience, the bottom command will save both to files, named after the server’s hostname:

user@both-servers:~ $ wg genkey | tee "$(hostname -s).wgpriv" | wg pubkey > "$(hostname -s).wgpub"

Both should look similar to this: gMFBuyVWbx/O9DAn2ajAhnNN0GAfZTM8u7d0HTJoqWs=

Configuring

The Wireguard config files reside in /etc/wireguard/. They are ini files, use the .conf extension and are named after their Wireguard interfaces (wg[number]).

You will also need to decide on a port (which you will have to open) and private IP address for each of the servers (on the same subnet, of course).

Our convention is to use the same iface name and port (starting at 51820) on both sides of a P-P link and IPv4 addresses in a /31 subnet. (iface wg[n] ipv4 10.0.[n].[0,1]/31 port 51820+[n])

Edit /etc/wireguard/wg[n].conf with root privs:

# P-P link to [other side]

# Our config
[Interface]
Address = [our INTERNAL address]/31
PrivateKey = [our privkey]
ListenPort = [our port]

# [other side]
[Peer]
PublicKey = [other side's pubkey]
AllowedIPs = [other side's INTERNAL address]/31
Endpoint = [other side's EXTERNAL address]:[other side's port]

An example for Cabbage would therefore be:

# P-P link to BEETroot

# Our config
[Interface]
Address = 10.0.0.1/31
PrivateKey = gMFBuyVWbx/O9DAn2ajAhnNN0GAfZTM8u7d0HTJoqWs=
ListenPort = 51820

# BEETroot
[Peer]
PublicKey = omFWU/kRAAQOU+31j6RoIPA7HLVSqW67BvQuZ9z1uxA=
AllowedIPs = 10.0.0.0/31
Endpoint = beetroot.example.com:51820

Applying and testing

Assuming the previous example (wg0):

# Apply config
user@both-servers:~ $ sudo wg-quick up wg0
# Start the connection
user@both-servers:~ $ systemctl start wg-quick@wg0
# Make start on startup (optional)
user@both-servers:~ $ systemctl enable wg-quick@wg0

Let’s see if it works (example output):

user@Cabbage:~ $ sudo wg show
interface: wg0
  public key: omFWU/kRAAQOU+31j6RoIPA7HLVSqW67BvQuZ9z1uxA=
  private key: (hidden)
  listening port: 51820

peer: omFWU/kRAAQOU+31j6RoIPA7HLVSqW67BvQuZ9z1uxA=
  endpoint: 198.51.100.42:51820
  allowed ips: 10.1.0.0/31
  latest handshake: 1 second ago
  transfer: 148 B received, 92 B sent

user@Cabbage:~ $ ping 10.1.0.0 -c 1
PING 10.1.0.0 (10.1.0.0) 56(84) bytes of data.
64 bytes from 10.1.0.0: icmp_seq=1 ttl=64 time=46.1 ms

--- 10.1.0.0 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 46.174/46.174/46.174/0.000 ms

OrangePi PC + cheap 3.5″ RPi TFT display clone

(WORK IN PROGRESS)

The connection

OPi pinPurposeLCD pin
13.3 V1
2 or 45 V4
6 or [see chart]GND6
19SPI 19
2121

Software

/dts-v1/;
/plugin/;

/ {
	compatible = "allwinner,sun8i-h3";

	fragment@0 {
		target = <&spi0>;
		__overlay__ {
			status = "okay";

			spidev@0{
				status = "disabled";
			};

			spidev@1{
				status = "disabled";
			};
		};
	};

	fragment@1 {
		target = <&pio>;
		__overlay__ {
			// Set up pins
			ili9486_pins: ili9486_pins {
				allwinner,pins = "PA2", "PC7"; // Add DC/RS nad RST pins here
				allwinner,function = "gpio_in";
			};
		};
	};

	fragment@2 {
		target = <&spi0>;
		__overlay__ {
			/* needed to avoid dtc warning */
			#address-cells = <1>;
			#size-cells = <0>;

			ili9486: ili9486@0{
				compatible = "ilitek,ili9486";
				reg = <0>;
				pinctrl-names = "default";
				pinctrl-0 = <&ili9486_pins>;

				spi-max-frequency = <16000000>;
				txbuflen = <32768>;
				rotate = <90>;
				bgr = <0>;
				fps = <30>;
				buswidth = <8>;
				regwidth = <16>;
				// Select pins: 1st number = pos. of letter in the alphabet - 1
				//              2nd number = pin number
				//              3rd number = DON'T CHANGE! (1 = ACTIVE_LOW, 0 = ACTIVE_HIGH)
				// Example: PA2 = <&pio 0 2 1>
				//          PC7 = <&pio 2 7 0>
				reset-gpios = <&pio 0 2 1>; // RST pin
				dc-gpios = <&pio 2 7 0>; // DC/RS pin
				debug = <0>;

				init = <0x10000b0 0x00
						0x1000011
					0x20000ff
					0x100003a 0x55
					0x1000036 0x28
					0x10000c2 0x44
					0x10000c5 0x00 0x00 0x00 0x00
					0x10000e0 0x0f 0x1f 0x1c 0x0c 0x0f 0x08 0x48 0x98 0x37 0x0a 0x13 0x04 0x11 0x0d 0x00
					0x10000e1 0x0f 0x32 0x2e 0x0b 0x0d 0x05 0x47 0x75 0x37 0x06 0x10 0x03 0x24 0x20 0x00
					0x10000e2 0x0f 0x32 0x2e 0x0b 0x0d 0x05 0x47 0x75 0x37 0x06 0x10 0x03 0x24 0x20 0x00
					0x1000036 0x28
					0x1000011
					0x1000029>;
			};

		};
	};

};

References

Display module pinout – “Official” product wiki – http://www.lcdwiki.com/MHS-3.5inch_RPi_Display#Hardware_Description
DTS template – Armbian forums – https://forum.armbian.com/topic/11701-35-lcd-ili9486-with-orange-pi-zero/
Misc. information about a similar module – Armbian forums – https://forum.armbian.com/topic/4656-orangepi-zero-mainline-kernel-spi-lcd-touchscreen/

SSH tunnels: forward, reverse and in between

We think of SSH as just a simple Secure SHell, but did you know it can also forward ports? Yes, of course you did! We’ve all probably read this exact sentence in probably hundreds if not thousands of articles out there. What you probably haven’t read, is an article that summarizes all of the different options in a nice, concise and copy-pastable way.


“Forward” port … forwarding

(TODO)

Reverse port forwarding

You’re in a closed network – NAT, firewall, trigger-happy intrusion detection, etc. You have a service running at a local address on a local port. You have a remote server with SSH. You want to access remote server on remote port and have your traffic be tunneled to your internal service.

user@[local machine]:~/ $ ssh [remote server] -R [remote port]:[local address]:[local port] 

Example:

I’m at work on my workstation. I’m running a crappy NodeJS app on localhost port 80. I want to show it off to a recruiter because I hate this job, but the sysadmin knows I want to leave so he won’t let me forward a port. I have a cheap VPS at cheap-vps.no-ip.org and want expose my app on port 8080.

user@workstation:~/ $ ssh shitty-vps.no-ip.org -R 8080:localhost:80

My shitty web app will now be accessible at http://shitty-vps.no-ip.org:8080/ and I will soon be safe from the stale coffee and bad hours of my current employer.

Whole-room notification LED

This whole project started several months ago, when I discovered the most amazing thing: 5m of RGB LEDs can be had for less than 5€ on AliExpress!

The idea was simple: my phone has a notification LED that blinks with different colors to signal that there are unread notifications, but since my phone is usually on my desk in a flip case (and probably under a stack of paper), I don’t usually see it. So how neat would it be, if my entire room mirrored that LED so I never missed a notification again?

If you don’t care about the LEDs and control circuits themselves and just want to know how I achieved notification LED mirroring, skip to this section.

The LEDs

The RGB(W) strip I chose is the RGBWW 5050 model. The WW part stands for Warm White, meaning the actual RGB LEDs alternate with warm white LEDs and the 5050 refers to the RGB LED model, which is the stronger of the two most common ones (the other being 3528).

I opted for RGBW instead of standard RBG as I wanted to be able to flash a notification color while still illuminating my room enough to work. The warm white (as opposed to a more standard, cooler white) is just a personal preference.

The driver circuit

The control server

I was initially planning on using my home NAS server to control the LEDs with just a long USB extension cable running to them, but looking at the pile of ESP8266s on my desk gave me a better idea: use esp-link to expose the driver board’s serial port over the network and control it directly.

As the ESP8266 requires a 3,3V supply, I had to step down some of the existing 5V even further. Luckily, I had some step-down boards handy and just jerry-rigged them onto the main board with some wires.

I loaded the ESP8266 with esp-link, which involved first updating its bootloader and then flashing the esp-link binary onto the chip. The configuration was also rather simple and involved connecting to an open Wi-Fi hotspot, created by the chip and entering the credentials to my home Wi-Fi, after which the ESP switched to station mode and acted just like any other device on my network.

I proceeded to connect the TX, RX and RST pins of my MCU to the ESP according to the official instructions, which gave me access to its serial port from any device on the network, as well as allowed me to remotely flash the MCU’s firmware at any time.

The firmware

The original firmware consisted of some 200 lines of C that were about as fast and fexible as an 80-year-old with osteoporosis. You can see it [here], but I don’t advise you to.

The firmware that I use now is actually not terrible and is available on GitLab [here]. You’re free to use it if you want to – it’s all GPLv2.
Because I can’t be bothered with proper documentation, here’s a short snippet that shows off the functionality (> is the prompt)

> asdf
E Invalid command!
> P ("print" the current state of the LEDs)
PR255G0B0W150
> SB150W0 ("set" specified channels to new values)
PR255G0B150W0
> FR0G0B0W0 ("fade" specified channels to new values
PR0G0B0

The firmware allows you to define (at compile time) any number of channels, each with a channel name (a single character) and its corresponding PWM output pin. You can see some examples in the README.

The notification mirroring

Ok, now for the interesting part. The never-before-seen part. The only part that hasn’t been posted all over Instructables 100s of times. How can I monitor the state of my phone’s notification LED and send that information to the light server?

Not all that surprisingly, there isn’t a simple function in the Android SDK that would allow me to just do that. So, I had to get creative and dig deeper into the system itself.

Looking through the sources of the particular ROM I was using (based on LineageOS, although the same applies for stock Android, too), [………..]

Once I know what function I am interested in, I need a way to tap into it and mess with its execution. Fortunately, that is exactly what the XPosed Framework was designed to do. After taking care of all the boilerplate required for XPosed to load my module into the right binary (which can be found here), I was able to tap the […] function like so:

public class Main implements IXposedHookLoadPackage {
    private static final String NMS_CLASS = "com.android.server.notification.NotificationManagerService";
    @Override public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
        if (lpparam.packageName.equals("android") && lpparam.processName.equals("android")) {
            XposedBridge.log("Hooked android");
            XposedHelpers.findAndHookMethod(NMS_CLASS, lpparam.classLoader, "updateLightsLocked", methodHook); 
        } 
    } 
}

This allows me to execute my code every time that function is called, just before it starts executing, giving me full control over the parameters it receives. Using a possibly one of the ugliest parts of Java – reflection, I was finally, after hours of staring at Android source code and dumping strange variables to logcat (because setting up an OS-level debugger is simply too much hassle), able to understand what all the variables meant and how they related to the LED’s action and came up with this beautiful mess of spaghetti code:

static XC_MethodHook methodHook = new XC_MethodHook() {
    @Override
    protected void afterHookedMethod(final MethodHookParam param) throws Throwable {
        // type: NotificationManagerService
        Object nms = param.thisObject;

        Field mNotificationLightField = NotificationManagerService.getDeclaredField("mNotificationLight");
        mNotificationLightField.setAccessible(true);

        // type: Light
        Object mNotificationLight = mNotificationLightField.get(nms);

        Class Light = mNotificationLight.getClass()

        Field mColorField = Light.getDeclaredField("mColor");
        mColorField.setAccessible(true);
        int mColor = mColorField.getInt(mNotificationLight);

        Field mFlashingField = Light.getDeclaredField("mFlashing");
        mFlashingField.setAccessible(true);
        boolean mFlashing = mFlashingField.getBoolean(mNotificationLight);

        XposedBridge.log("Light color: " + mColor);
        XposedBridge.log("Light flashing: " + mFlashing);
    }
};

Tying it all together

With all the pieces working, it was time to connect everything together. For every light event, after the above code decoded it, the details of the event (ON/OFF, color, blinking speed) would be send to the light server in the form of a special command. The server would forward it to the MCU, where the firmware would read the details and save them into its memory.

With the LED state information now synchronized to the controller, it was simply a matter of writing a fade function to mimic the one used by my phone and the project was done.

The ultimate presentation setup

We’ve all been there. You’re all set for your big presentation: beautiful template, just the right amount of text, fancy graphics and that one perfectly timed star wipe for comedic effect. You’re just about to leave when you remember, that the world isn’t an Apple commercial. You pack the HDMI-VGA adapter and a second one for backup, your brand new absurdly expensive slide clicker, you make sure your presentations looks good on a 4:3 screen too and walk into the hall with the confidence of a Python developer, only to be greeted by a hard-wired Windows XP computer with Office 2007…

I won’t bother you with any more stories. Here’s what I want: I want to be able to walk around with a tablet in my hand, the presenter notes on it and generally run whatever I want on it without having to worry about compatibility ever again. Why? Mostly because it looks futuristic and cool.

Possible easy solutions

Let’s get some of the obvious solutions out of the way first:

  • Office365 PowerPoint broadcast: low quality, requires a good Internet connection on both sides, works only in PowerPoint
  • Windows 10 “Stream to this PC”: only works on Windows 10, requires a (compatible) Wi-Fi card in the target PC
  • Miracast/Chromecast/WhateverCast: rarely available, even if I had my own dongle it would still require messing with the projector

Requirements

If you want to use this setup, here’s what you’ll need:

  • A rooted Android smartphone (that supports USB tethering)
  • A Windows (7 or above) laptop (a touch 2-in-1 is even better)
  • A USB cable for your phone

The virtual display

The trickiest part of this setup is the virtual display. I don’t want to stream my laptop’s display, because I want to have my presenter view on it. I want Windows to think there’s an actual extra physical monitor connected to it. This is something that is very hard to achieve, but luckily, some Windows driver magicians over at datronicsoft have already done that and packaged into a rather neat application called spacedesk.

After installing the spacedesk driver on the laptop, I can connect to it from any device on the network using one of several available viewers: Windows, Android, iOS and HTML5. I’m interested in the HTML5 one because it is as cross-platform as anything can possibly be.

The viewer

Spacedesk’s HTML5 viewer is normally available on their website, but for several reasons, I want to serve the files to the target computer myself. A quick CTRL+S gets me the page and all of its JS into a folder that I can serve with a simple web server on my phone.

Out-of-the-box, the viewer doesn’t support Mozilla Firefox for “performance reasons”, but in my testing, it works just fine and I still prefer a slow connection to no connection at all, so a simple search-and-replace on the source code gets rid of the browser checks:

sed -i 's/browser.name === "Firefox"/\0 \&\& false/' spacedesk.min.js

Some further modification was done to pre-populate the address field with the (static) address of the phone’s tethering interface.

A simple httpd from Busybox will suffice to serve the files from the phone and present them to the target computer.

The network

// TODO

The proxy

// TODO



Xiaomi Yi camera

General info

  • OS: Buildroot
  • CPU: ARMv6-compatible processor rev 5 (v6l)
  • BogoMIPS: 524.28
  • Memory: 37MB

The root filesystem is rootfs, which is stored in memory and therefore wiped on reboot. The Micro SD card is mounted at /tmp/fuse_d/. Something is also mounted at /tmp/fuse_a and /tmp/fuse_z.

WiFi AP

  • SSID: YDXJ_[last 7 digits of SN]
  • Default pass: 1234567890
  • Securtiy: WPA2-Personal
  • Camera IP: 192.168.42.1

Nmap scan:

Opened ports on 192.168.42.1 
Discovered open port 80/tcp on 192.168.42.1
Discovered open port 554/tcp on 192.168.42.1
Discovered open port 53/tcp on 192.168.42.1
Discovered open port 8787/tcp on 192.168.42.1
Discovered open port 7878/tcp on 192.168.42.1

Shell access

To enable shell access you must create an empty file on the SD card named enable_info_display.script. Then you can connect to the camera using telnet:

telnet 192.168.42.1 23

You will be presented a login promt:

buildroot login: 

Type root and hit enter. You are now connected to the camera’s root Linux shell


Telnet commands

Technically just a raw TCP socket connection, but it’s easier to just call it telnet.

telnet 192.168.42.1 7878

Meta

Error messages sent by the camera:

  • {"rval": -7} – Input is not a valid JSON object
  • {"rval":-4,"msg_id":0} – Input object is empty
  • {"rval":-9,"msg_id":0} – Input object is not a valid command

Token

All requests require a token you have to request when connecting to the camera:

Request:

{"msg_id":257, "token":0}

Response:

{ "rval": 0, "msg_id": 257, "param": 1 }

param is your token. All requests in this article have the token set to 1. This will be different for you.

Config

Request:

{"msg_id":3, "token":1} 

Response:

NOTE: The config is a list of objects of settings, not just an object of settings. You have to use a loop!

{
	"rval": 0,
	"msg_id": 3,
	"param": [
		{"camera_clock": "2015-04-07 02:32:29"},
		{"video_standard": "NTSC"},
		{"app_status": "idle"},
		{"video_resolution": "1920x1080 60P 16:9"},
		{"video_stamp": "off"},
		{"video_quality": "S.Fine"},
		{"timelapse_video": "off"},
		{"capture_mode": "precise quality"},
		{"photo_size": "16M (4608x3456 4:3)"},
		{"photo_stamp": "off"},
		{"photo_quality": "S.Fine"},
		{"timelapse_photo": "60"},
		{"preview_status": "on"},
		{"buzzer_volume": "mute"},
		{"buzzer_ring": "off"},
		{"capture_default_mode": "precise quality"},
		{"precise_cont_time": "60.0 sec"},
		{"burst_capture_number": "7 p / s"},
		{"restore_factory_settings": "on"},
		{"led_mode": "all enable"},
		{"dev_reboot": "on"},
		{"meter_mode": "center"},
		{"sd_card_status": "insert"},
		{"video_output_dev_type": "tv"},
		{"sw_version": "YDXJv22_1.0.7_build-20150330113749_b690_i446_s699"},
		{"hw_version": "YDXJ_v22"},
		{"dual_stream_status": "on"},
		{"streaming_status": "off"},
		{"precise_cont_capturing": "off"},
		{"piv_enable": "off"},
		{"auto_low_light": "on"},
		{"loop_record": "off"},
		{"warp_enable": "off"},
		{"support_auto_low_light": "on"},
		{"precise_selftime": "5s"},
		{"precise_self_running": "off"},
		{"auto_power_off": "5 minutes"},
		{"serial_number": "xxxxx"},
		{"system_mode": "capture"},
		{"system_default_mode": "capture"},
		{"start_wifi_while_booted": "off"},
		{"quick_record_time": "0"},
		{"precise_self_remain_time": "0"},
		{"sdcard_need_format": "no-need"},
		{"video_rotate": "off"}
	]
}

Photo capture

Request:

{"msg_id":769,"token":1}

Response:

{ "msg_id": 7, "type": "start_photo_capture", "param":"precise quality;off"}
{ "msg_id": 7, "type": "photo_taken", "param":"/tmp/fuse_d/DCIM/100MEDIA/YDXJ0047.jpg"}
  • param is the full path of the image

NOTE: To get the download URL of the image, replace /tmp/fuse_d/ with http://192.168.42.1/.

Telnet events

All messages with msg_id: 7 are events and are sent automatically by the camera.

{ "msg_id": 7, "type": "<string: event name>"}

Some events also provide additional data in the param attribute:

{ "msg_id": 7, "type": "<string: event name>", "param":"<int: event data>"}

Below is a list of known events, their parameters and an example for each

Battery

Fired when the battery level changes

{ "msg_id": 7, "type": "battery", "param":"20"}
  • type is battery when discharging and adapter when charging
  • param is the battery charge percentage (int 1-100, obviously)

Charger status

Fired when the USB charging cable is connected/disconnected

{ "msg_id": 7, "type": "adapter_status" ,"param":"1"}
  • param:0 – cable disconnected
  • param:1 – cable connected

Camera mode switched

Fired when the recording mode is changed (big button on the front)

{ "msg_id": 7, "type": "switch_to_rec_mode" }
  • type:"switch_to_rec_mode" – switched to video mode
  • type:"switch_to_cap_mode" – switched to photo mode

Photo taken

{ "msg_id": 7, "type": "start_photo_capture" ,"param":"precise quality;off"}
{ "msg_id": 7, "type": "precise_capture_data_ready" }
{ "msg_id": 7, "type": "photo_taken" ,"param":"/tmp/fuse_d/DCIM/100MEDIA/YDXJ0513.jpg"}

SD Card status

Fired when the SD card is inserted/removed

{ "msg_id": 7, "type": "sd_card_status" ,"param":"insert"}
  • type:"insert" – SD card inserted
  • type:"remove" – SD card removed When SD card is removed, the following event also fires:
{ "msg_id": 7, "type": "CARD_REMOVED" }

Live view

After you’ve successfully authenticated through telnet, you can connect to rtsp://192.168.42.1:554/live with VLC (or similar) to see the live view of the camera.

This doesn’t always work. The only 100% way I know is to first connect with the official app.

Unknown events

Photo taken vf_start/stop

Fired after start_photo_capture Best guess: stop live view

{ "msg_id": 7, "type": "vf_stop" }

Fired after photo_taken Best guess: start live view

{ "msg_id": 7, "type": "vf_start" }

vf == “viewfinder” ?

Battery status

This message has been observed when taking out the battery with the cam on USB power, but I haven’t been able to reproduce it so far.

{ "msg_id": 7, "type": "battery_status" ,"param":"0"}
  • param – observed values0 and -1

Some of my favourite free, modern-looking, easy to use, web-based email clients that anyone can install and use with their mail server (in no particular order).

Roundcube
WebsiteScreenshots

  • Built-in calendar, contacts and task list
  • VERY advanced filters
  • PGP support
  • Multiple identities/e-mail accounts
  • Many – too many – skins (free and paid)
  • A few responsive skins (default isn’t)

RainLoop
WebsiteScreenshotsDemo

  • Single-pane or horizontal/vertical split
  • Advanced filtering (using Sieve)
  • Two-factor authentication
  • OpenPGP support (client-side JS)
  • No mobile support

MailPile
WebsiteDemo

  • Tag-based sorting
  • OpenPGP support
  • Multiple identities/e-mail accounts
  • No mobile support
  • Not (yet) multi-user

AfterLogin WebMail Lite
WebsiteDemo

  • Contact manager with groups
  • Multiple identities/e-mail accounts
  • No mobile support

Neutron (ProtonMail WebClient)
GitHubScreenshots

  • A server for the ProtonMail WebClient
  • OpenPGP support
  • Fully responsive
« Older posts

Copyright © 2024 Bloggity Blog

Theme by Anders NorenUp ↑